diff --git a/.devcontainer/dev_container.dockerfile b/.devcontainer/dev_container.dockerfile
new file mode 100644
index 0000000..f602887
--- /dev/null
+++ b/.devcontainer/dev_container.dockerfile
@@ -0,0 +1,29 @@
+# Base image for the development container
+ARG BASE_URL=python:3.12-slim
+FROM ${BASE_URL}
+
+USER root
+
+# We'll be running as a non-root user in a container and may want root permissions
+RUN apt update && apt -y install nano ssh sudo && apt clean
+
+# Install setup tools and dependencies
+WORKDIR /app
+COPY . /app
+RUN pip install --upgrade pip setuptools && pip install .[dev]
+
+# Set up user to match the host OS (https://stackoverflow.com/a/78621662/415551)
+ARG HOST_USER
+ARG HOST_UID
+ARG HOST_GID
+
+RUN addgroup --gid ${HOST_GID} ${HOST_USER} \
+ && adduser --gecos "" --disabled-password --uid ${HOST_UID} --gid ${HOST_GID} ${HOST_USER} \
+ && usermod -aG sudo ${HOST_USER} \
+ && echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
+
+ENV HOME /home/${HOST_USER}
+ENV TMPDIR=/tmp
+WORKDIR /home/${HOST_USER}
+
+USER ${HOST_USER}
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..9c1d714
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,51 @@
+// For format details, see https://aka.ms/devcontainer.json. For config options, see the
+// README at: https://github.com/devcontainers/templates/tree/main/src/python
+{
+ "name": "Unit Test Image",
+ "remoteUser": "${localEnv:USER}",
+ "build": {
+ // Sets the build context to one level up instead of the .devcontainer folder.
+ "context": "..",
+ "dockerfile": "dev_container.dockerfile",
+ "args": {
+ "HOST_USER": "${localEnv:USER}",
+ "HOST_UID": "${localEnv:HOST_UID:1000}",
+ "HOST_GID": "${localEnv:HOST_GID:1000}"
+ }
+ },
+ // Features to add to the dev container. More info: https://containers.dev/features.
+ "features": {
+ "ghcr.io/devcontainers/features/git:1": {}
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "donjayamanne.git-extension-pack",
+ "eamodio.gitlens",
+ "mhutchie.git-graph",
+ "ms-azuretools.vscode-docker",
+ "ms-python.python",
+ "ms-python.debugpy",
+ "ms-python.black-formatter",
+ "ms-python.flake8",
+ "ms-python.vscode-pylance",
+ "njpwerner.autodocstring",
+ "redhat.vscode-yaml",
+ "SonarSource.sonarlint-vscode"
+ ],
+ "settings": {
+ "flake8.args": [
+ "--max-line-length=120"
+ ]
+ }
+ }
+ }
+ // Use 'forwardPorts' to make a list of ports inside the container available locally.
+ // "forwardPorts": [],
+ // Uncomment the next line to run commands after the container is created.
+ // "postCreateCommand": "cat /etc/os-release",
+ // Configure tool-specific properties.
+ // "customizations": {},
+ // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root.
+ // "remoteUser": "devcontainer"
+}
\ No newline at end of file
diff --git a/.github/workflows/github-python-workflow.yml b/.github/workflows/github-python-workflow.yml
new file mode 100644
index 0000000..3e3e5a9
--- /dev/null
+++ b/.github/workflows/github-python-workflow.yml
@@ -0,0 +1,105 @@
+# This workflow handles linting, testing, and SonarQube scanning for the OGC repository.
+name: Python Workflow
+
+on:
+ push:
+ branches: [ main, develop ]
+ pull_request:
+ types: [ opened, synchronize, reopened ]
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ fetch-tags: true
+
+ - name: Set up Python
+ uses: actions/setup-python@v3
+ with:
+ python-version: "3.12"
+
+ - name: Install dependencies
+ run: |
+ pip install --upgrade pip setuptools && pip install .[dev]
+
+ - name: Run flake8 lint checks
+ run: |
+ # stop the build if there are Python syntax errors or undefined names
+ flake8 . --count --ignore=E,W,D,I,N806,N815,N818,Q000,Q001,Q002,S001,B008,B028 --show-source --statistics --max-line-length=120 --output-file=flake8-report.txt
+
+ - name: Run black format checks
+ run: black --check --diff -l 120 ogc example > black-report.txt
+
+ - name: Artifact flake8 report
+ uses: actions/upload-artifact@v4
+ with:
+ name: flake8-report
+ path: flake8-report.txt
+ retention-days: 1
+
+ - name: Artifact black report
+ uses: actions/upload-artifact@v4
+ with:
+ name: black-report
+ path: black-report.txt
+ retention-days: 1
+
+ unit_testing:
+ name: Unit Testing
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ fetch-tags: true
+
+ - name: Set up Python
+ uses: actions/setup-python@v3
+ with:
+ python-version: "3.12"
+
+ - name: Install dependencies
+ run: |
+ pip install --upgrade pip setuptools && pip install .[dev]
+
+ - name: Run pytest with coverage
+ run: |
+ coverage run --data-file=coverage.bin --branch \
+ -m pytest --continue-on-collection-errors || \
+ { echo "WARNING: Some tests have failed. (Temporarily ignored)"; true; }
+ coverage xml --data-file=coverage.bin -o coverage.xml
+ sed -i 's,.*/ogc,ogc,' coverage.xml
+ - name: Artifact coverage document
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage
+ path: coverage.xml
+ retention-days: 1
+
+ sonarqube_scan:
+ name: SonarQube Scan
+ needs: [ unit_testing ]
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ fetch-tags: true
+
+ - name: Download coverage artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: coverage
+ path: .
+
+ - name: SonarQube Scan
+ uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602
+ env:
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..08cb765
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,7 @@
+repos:
+- repo: https://github.com/ambv/black
+ rev: stable
+ hooks:
+ - id: black
+ language_version: python3
+ files: (^ogc|^example)
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..1873b22
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,20 @@
+# Changelog
+## 0.5.0
+
+### Introduction
+Support for Environmental Data Retrieval (EDR) was added in this version to extend OGC capabilities.
+EDR is optional and can be enabled via the OGC_SUPPORTED_FORMATS environmental variable.
+The currently supported version of EDR is v1.1.0. Additionally, the repository was modernized by
+adding a development container, pyproject.toml, and Github Actions for linting, formatting,
+unit testing and scanning.
+
+### Features
+* Added support for EDR v1.1.0.
+
+### Maintenance
+* Added unit testing for code coverage requirements.
+* Added development container and pyproject.toml file.
+* Added Github Actions for linting, formatting, unit testing, and scanning.
+
+### Bugfixes
+* Fixed formatting, linting, and SonarQube errors to pass CI/CD scans.
\ No newline at end of file
diff --git a/example/app.py b/example/app.py
index 8aff167..b8e11e4 100755
--- a/example/app.py
+++ b/example/app.py
@@ -2,33 +2,43 @@
Demo Flask app using PODPAC Node backed layers.
"""
-from flask import Flask
from datetime import datetime
import re
from ogc import servers
from ogc import core
from ogc import podpac as pogc
+from ogc.settings import EDR_TIME_INSTANCE_DIMENSION
import podpac
import numpy as np
+# Setup new dimension
+podpac.core.coordinates.utils.add_valid_dimension(EDR_TIME_INSTANCE_DIMENSION)
+
# create some podpac nodes
-data = np.random.rand(11, 21)
+data = np.random.default_rng(1).random((11, 21))
lat = np.linspace(90, -90, 11)
lon = np.linspace(-180, 180, 21)
coords = podpac.Coordinates([lat, lon], dims=["lat", "lon"])
node1 = podpac.data.Array(source=data, coordinates=coords)
-data2 = np.random.rand(11, 21)
+data2 = np.random.default_rng(1).random((11, 21))
node2 = podpac.data.Array(source=data2, coordinates=coords)
+time = np.array(["2025-10-24T12:00:00"], dtype="datetime64")
+instance = np.array(["2025-10-24T00:00:00"], dtype="datetime64")
+coords = podpac.Coordinates([lat, lon, time, instance], dims=["lat", "lon", "time", EDR_TIME_INSTANCE_DIMENSION])
+data3 = np.random.default_rng(1).random((11, 21, 1, 1))
+node3 = podpac.data.Array(source=data3, coordinates=coords)
+
# use podpac nodes to create some OGC layers
layer1 = pogc.Layer(
node=node1,
identifier="layer1",
title="OGC/POPAC layer containing random data",
abstract="This layer contains some random data",
+ group="Layers",
)
layer2 = pogc.Layer(
@@ -37,9 +47,19 @@
title="FOUO: Another OGC/POPAC layer containing random data",
abstract="Marked as FOUO. This layer contains some random data. Same coordinates as layer1, but different values.",
is_fouo=True,
+ group="Layers",
+)
+
+layer3 = pogc.Layer(
+ node=node3,
+ identifier="layer3",
+ title="OGC/POPAC layer containing random data with time instances available.",
+ abstract="This layer contains some random data with time instances available.",
+ group="Layers",
+ valid_times=[dt.astype(datetime) for dt in time],
)
-all_layers = [layer1, layer2]
+all_layers = [layer1, layer2, layer3]
non_fouo_layers = [layer for layer in all_layers if not layer.is_fouo]
# create a couple of different ogc endpoints
@@ -69,16 +89,25 @@ def api_home(endpoint):
WMS GetLegend Example (PNG) (v1.3.0)
+ EDR: Open Geospatial Consortium (OGC) Environmental Data Retrieval (EDR) (v1.0.1)
+
+
"""
app = servers.FlaskServer(__name__, ogcs=[NonFouoOGC, FouoOGC], home_func=api_home)
+
# add in some other endpoints.
@app.route("/")
def home():
- return f'This is an example OGC flask app. See FULL and PARTIAL endpoints.'
+ return """This is an example OGC flask app.
+ See FULL and PARTIAL endpoints."""
@app.route("/layers/")
@@ -87,7 +116,7 @@ def check_layers(layer):
if match_object:
clean_layer = match_object.group(0)
- if clean_layer in [l.identifier for l in all_layers]:
+ if clean_layer in [available_layer.identifier for available_layer in all_layers]:
return "{} is an available layer id".format(clean_layer)
else:
return "No layer available with that id"
diff --git a/ogc/__init__.py b/ogc/__init__.py
index 7e130a5..b8aa1a1 100755
--- a/ogc/__init__.py
+++ b/ogc/__init__.py
@@ -1,5 +1,5 @@
"""
-OGC WMS/WCS (v1.3.0/v1.0.0) server
+OGC WMS/WCS (v1.3.0/v1.0.0) server
"""
import traitlets as tl
@@ -70,10 +70,9 @@ class Layer(tl.HasTraits):
identifier = tl.Unicode()
title = tl.Unicode(default_value="An OGC Layer")
abstract = tl.Unicode(default_value="This is an example OGC Layer")
+ group = tl.Unicode(default_value="Default")
is_fouo = tl.Bool(default_value=False)
- grid_coordinates = tl.Instance(
- klass=GridCoordinates, default_value=GridCoordinates()
- )
+ grid_coordinates = tl.Instance(klass=GridCoordinates, default_value=GridCoordinates())
valid_times = tl.List(
trait=tl.Instance(datetime.datetime),
default_value=tl.Undefined,
@@ -101,9 +100,7 @@ def __init__(self, *args, **kwargs):
elif "title" in kwargs:
string_repr = kwargs["title"]
if "is_enumerated" in kwargs:
- self._style = Style(
- string_repr=string_repr, is_enumerated=kwargs["is_enumerated"]
- )
+ self._style = Style(string_repr=string_repr, is_enumerated=kwargs["is_enumerated"])
else:
self._style = Style(string_repr=string_repr)
if self.valid_times is not tl.Undefined:
diff --git a/ogc/core.py b/ogc/core.py
index ec9e392..776161a 100755
--- a/ogc/core.py
+++ b/ogc/core.py
@@ -3,6 +3,7 @@
Currently holds some definitions for interface classes.
"""
+
import gc
import logging
import traitlets as tl
@@ -12,12 +13,16 @@
from . import wms_request_1_3_0
from . import wcs_response_1_0_0
from . import wms_response_1_3_0
-from . import ogc_common
+from .edr import EdrRoutes
from ogc.ogc_common import WCSException
logger = logging.getLogger(__name__)
+LOAD_FAILURE = "Failed to load and validate: "
+INVALID_ARGUMENTS = "Invalid arguments"
+
+
class OGC(tl.HasTraits):
wms_capabilities = tl.Instance(klass=wms_response_1_3_0.Capabilities)
@@ -25,9 +30,7 @@ class OGC(tl.HasTraits):
endpoint = tl.Unicode(default_value="/ogc", allow_none=True)
service_title = tl.Unicode(default_value="OGC Server", allow_none=True)
- service_abstract = tl.Unicode(
- default_value="An example OGC Server", allow_none=True
- )
+ service_abstract = tl.Unicode(default_value="An example OGC Server", allow_none=True)
server_address = tl.Unicode(default_value="http://127.0.0.1:5000", allow_none=True)
service_group_title = tl.Unicode(default_value="Data Products", allow_none=True)
@@ -35,8 +38,9 @@ class OGC(tl.HasTraits):
def base_url(self):
return "{}{}?".format(self.server_address, self.endpoint)
- def __init__(self, layers=[], **kwargs):
+ def __init__(self, layers=None, **kwargs):
super().__init__(**kwargs)
+ layers = layers if layers is not None else []
coverages = [
wcs_response_1_0_0.Coverage(
layer=layer,
@@ -59,7 +63,11 @@ def __init__(self, layers=[], **kwargs):
service_abstract=self.service_abstract,
service_group_title=self.service_group_title,
)
- return
+ self.edr_routes = (
+ EdrRoutes(base_url=f"{self.server_address}{self.endpoint}/edr", layers=layers)
+ if settings.EDR_ENABLED
+ else None
+ )
def get_coverage_from_id(self, identifier):
for coverage in self.wcs_capabilities.coverages:
@@ -71,22 +79,81 @@ def get_coverage_from_id(self, identifier):
exception_text="Invalid coverage {}".format(identifier),
)
- def handle_wcs_kv(self, args):
- if args["request"] == "GetCapabilities":
- get_capabilities = wcs_request_1_0_0.GetCapabilities()
- try:
- get_capabilities.load_from_kv(args)
- get_capabilities.validate()
- except:
- logger.error("Failed to load and validate: ", exc_info=True)
- raise WCSException(exception_text="Invalid arguments")
+ def get_capabilities_wcs(self, args):
+ get_capabilities = wcs_request_1_0_0.GetCapabilities()
+ try:
+ get_capabilities.load_from_kv(args)
+ get_capabilities.validate()
+ except Exception:
+ logger.error(LOAD_FAILURE, exc_info=True)
+ raise WCSException(exception_text=INVALID_ARGUMENTS)
+
+ capabilities = self.wcs_capabilities
+
+ if args["base_url"]:
+ capabilities.base_url = args["base_url"]
+
+ return capabilities.to_xml()
+
+ def describe_coverage_wcs(self, args, wcs_request, wcs_response):
+ describe_coverage = wcs_request.DescribeCoverage()
+ try:
+ describe_coverage.load_from_kv(args)
+ describe_coverage.validate()
+ except Exception:
+ logger.error(LOAD_FAILURE, exc_info=True)
+ raise WCSException(exception_text=INVALID_ARGUMENTS)
+
+ coverages = [self.get_coverage_from_id(identifier.value) for identifier in describe_coverage.identifiers]
+ coverage_description = wcs_response.CoverageDescription(coverages=coverages)
+
+ return coverage_description.to_xml()
+
+ def get_coverage_wcs(self, args, wcs_request):
+ get_coverage = wcs_request.GetCoverage()
+ try:
+ get_coverage.load_from_kv(args)
+ get_coverage.validate()
+ except Exception:
+ logger.error(LOAD_FAILURE, exc_info=True)
+ raise WCSException(exception_text=INVALID_ARGUMENTS)
+
+ coverage = self.get_coverage_from_id(get_coverage.identifier.value)
+
+ if get_coverage.width == 0:
+ raise WCSException(
+ exception_code="InvalidParameterValue",
+ locator="VERSION",
+ exception_text="Grid coordinates x_size must be greater than 0",
+ )
+ if get_coverage.height == 0:
+ raise WCSException(
+ exception_code="InvalidParameterValue",
+ locator="VERSION",
+ exception_text="Grid coordinates y_size must be greater than 0",
+ )
+ if get_coverage.height * get_coverage.width > settings.MAX_GRID_COORDS_REQUEST_SIZE:
+ raise WCSException(
+ exception_code="InvalidParameterValue",
+ locator="VERSION",
+ exception_text="Grid coordinates x_size * y_size must be less than %d"
+ % settings.MAX_GRID_COORDS_REQUEST_SIZE,
+ )
+
+ fp = coverage.layer.get_coverage(args)
+
+ fn = coverage.identifier.split(".")[-1] + ".tif"
+
+ # Collect garbage
+ gc.collect()
- capabilities = self.wcs_capabilities
+ response = {"fp": fp, "fn": fn}
- if args["base_url"]:
- capabilities.base_url = args["base_url"]
+ return response
- return capabilities.to_xml()
+ def handle_wcs_kv(self, args):
+ if args["request"] == "GetCapabilities":
+ return self.get_capabilities_wcs(args)
if "version" in args and args["version"] == "1.0.0":
wcs_response = wcs_response_1_0_0
@@ -99,87 +166,96 @@ def handle_wcs_kv(self, args):
)
if args["request"] == "DescribeCoverage":
+ return self.describe_coverage_wcs(args, wcs_request, wcs_response)
- describe_coverage = wcs_request.DescribeCoverage()
- try:
- describe_coverage.load_from_kv(args)
- describe_coverage.validate()
- except:
- logger.error("Failed to load and validate: ", exc_info=True)
- raise WCSException(exception_text="Invalid arguments")
+ elif args["request"] == "GetCoverage":
+ return self.get_coverage_wcs(args, wcs_request)
- coverages = [
- self.get_coverage_from_id(identifier.value)
- for identifier in describe_coverage.identifiers
- ]
- coverage_description = wcs_response.CoverageDescription(coverages=coverages)
+ raise WCSException(exception_text="KV Request not handled properly: " + str(args))
- return coverage_description.to_xml()
+ def get_capabilities_wms(self, args):
+ get_capabilities = wms_request_1_3_0.GetCapabilities()
+ try:
+ get_capabilities.load_from_kv(args)
+ get_capabilities.validate()
+ except Exception:
+ logger.error(LOAD_FAILURE, exc_info=True)
+ raise WCSException(exception_text=INVALID_ARGUMENTS)
- elif args["request"] == "GetCoverage":
- get_coverage = wcs_request.GetCoverage()
- try:
- get_coverage.load_from_kv(args)
- get_coverage.validate()
- except:
- logger.error("Failed to load and validate: ", exc_info=True)
- raise WCSException(exception_text="Invalid arguments")
+ wms_capabilities = self.wms_capabilities
- coverage = self.get_coverage_from_id(get_coverage.identifier.value)
+ if args["base_url"]:
+ wms_capabilities.base_url = args["base_url"]
+ return wms_capabilities.to_xml()
- from dateutil.parser import parse
+ def get_legend_graphic(self, args, wms_request):
+ get_legend_graphic = wms_request.GetLegendGraphic()
+ try:
+ get_legend_graphic.load_from_kv(args)
+ get_legend_graphic.validate()
+ except Exception:
+ logger.error(LOAD_FAILURE, exc_info=True)
+ raise WCSException(exception_text=INVALID_ARGUMENTS)
+ coverage = self.get_coverage_from_id(get_legend_graphic.layer.value)
- if get_coverage.width == 0:
- raise WCSException(
- exception_code="InvalidParameterValue",
- locator="VERSION",
- exception_text="Grid coordinates x_size must be greater than 0",
- )
- if get_coverage.height == 0:
- raise WCSException(
- exception_code="InvalidParameterValue",
- locator="VERSION",
- exception_text="Grid coordinates y_size must be greater than 0",
- )
- if get_coverage.height * get_coverage.width > settings.MAX_GRID_COORDS_REQUEST_SIZE:
- raise WCSException(
- exception_code="InvalidParameterValue",
- locator="VERSION",
- exception_text="Grid coordinates x_size * y_size must be less than %d" % settings.MAX_GRID_COORDS_REQUEST_SIZE,
- )
+ fp = coverage.layer.get_legend_graphic(args)
+ fn = coverage.identifier.split(".")[-1] + ".png"
- fp = coverage.layer.get_coverage(args)
+ response = {"fp": fp, "fn": fn}
+ return response
+
+ def get_map(self, args, wms_request):
+ get_map = wms_request.GetMap()
+ try:
+ get_map.load_from_kv(args)
+ get_map.validate()
+ except Exception:
+ logger.error(LOAD_FAILURE, exc_info=True)
+ raise WCSException(exception_text=INVALID_ARGUMENTS)
+
+ coverage = self.get_coverage_from_id(get_map.layer.value)
+
+ # Make sure the request size is correct
+ if get_map.width == 0:
+ raise WCSException(
+ exception_code="InvalidParameterValue",
+ locator="VERSION",
+ exception_text="Grid coordinates x_size must be greater than 0",
+ )
+ if get_map.height == 0:
+ raise WCSException(
+ exception_code="InvalidParameterValue",
+ locator="VERSION",
+ exception_text="Grid coordinates y_size must be greater than 0",
+ )
+ if get_map.height * get_map.width > settings.MAX_GRID_COORDS_REQUEST_SIZE:
+ raise WCSException(
+ exception_code="InvalidParameterValue",
+ locator="VERSION",
+ exception_text="Grid coordinates x_size * y_size must be less than %d"
+ % settings.MAX_GRID_COORDS_REQUEST_SIZE,
+ )
- fn = coverage.identifier.split(".")[-1] + ".tif"
+ try:
+ fp = coverage.layer.get_map(args)
+ except Exception:
+ logger.error("Failed to get_map from layer: ", exc_info=True)
+ raise WCSException(exception_text=INVALID_ARGUMENTS)
- # Collect garbage
- gc.collect()
+ fn = coverage.identifier.split(".")[-1] + ".png"
- response = {"fp": fp, "fn": fn}
+ # Collect garbage
+ gc.collect()
- return response
+ response = {"fp": fp, "fn": fn}
- raise WCSException(
- exception_text="KV Request not handled properly: " + str(args)
- )
+ return response
def handle_wms_kv(self, args):
if args["request"] == "GetCapabilities":
- get_capabilities = wms_request_1_3_0.GetCapabilities()
- try:
- get_capabilities.load_from_kv(args)
- get_capabilities.validate()
- except:
- logger.error("Failed to load and validate: ", exc_info=True)
- raise WCSException(exception_text="Invalid arguments")
-
- wms_capabilities = self.wms_capabilities
-
- if args["base_url"]:
- wms_capabilities.base_url = args["base_url"]
- return wms_capabilities.to_xml()
+ return self.get_capabilities_wms(args)
if args["request"] == "GetFeatureInfo":
raise WCSException(
@@ -198,70 +274,9 @@ def handle_wms_kv(self, args):
)
if args["request"].lower() == "getlegendgraphic":
- get_legend_graphic = wms_request.GetLegendGraphic()
- try:
- get_legend_graphic.load_from_kv(args)
- get_legend_graphic.validate()
- except:
- logger.error("Failed to load and validate: ", exc_info=True)
- raise WCSException(exception_text="Invalid arguments")
-
- coverage = self.get_coverage_from_id(get_legend_graphic.layer.value)
-
- fp = coverage.layer.get_legend_graphic(args)
-
- fn = coverage.identifier.split(".")[-1] + ".png"
-
- response = {"fp": fp, "fn": fn}
- return response
+ return self.get_legend_graphic(args, wms_request)
if args["request"].lower() == "getmap":
+ return self.get_map(args, wms_request)
- get_map = wms_request.GetMap()
- try:
- get_map.load_from_kv(args)
- get_map.validate()
- except:
- logger.error("Failed to load and validate: ", exc_info=True)
- raise WCSException(exception_text="Invalid arguments")
-
- coverage = self.get_coverage_from_id(get_map.layer.value)
-
- # Make sure the request size is correct
- if get_map.width == 0:
- raise WCSException(
- exception_code="InvalidParameterValue",
- locator="VERSION",
- exception_text="Grid coordinates x_size must be greater than 0",
- )
- if get_map.height == 0:
- raise WCSException(
- exception_code="InvalidParameterValue",
- locator="VERSION",
- exception_text="Grid coordinates y_size must be greater than 0",
- )
- if get_map.height * get_map.width > settings.MAX_GRID_COORDS_REQUEST_SIZE:
- raise WCSException(
- exception_code="InvalidParameterValue",
- locator="VERSION",
- exception_text="Grid coordinates x_size * y_size must be less than %d" % settings.MAX_GRID_COORDS_REQUEST_SIZE,
- )
-
- try:
- fp = coverage.layer.get_map(args)
- except:
- logger.error("Failed to get_map from layer: ", exc_info=True)
- raise WCSException(exception_text="Invalid arguments")
-
- fn = coverage.identifier.split(".")[-1] + ".png"
-
- # Collect garbage
- gc.collect()
-
- response = {"fp": fp, "fn": fn}
-
- return response
-
- raise WCSException(
- exception_text="KV Request not handled properly: " + str(args)
- )
+ raise WCSException(exception_text="KV Request not handled properly: " + str(args))
diff --git a/ogc/edr/__init__.py b/ogc/edr/__init__.py
new file mode 100644
index 0000000..68fde54
--- /dev/null
+++ b/ogc/edr/__init__.py
@@ -0,0 +1,3 @@
+from .edr_routes import EdrRoutes
+
+__all__ = ["EdrRoutes"]
diff --git a/ogc/edr/config/default.json b/ogc/edr/config/default.json
new file mode 100644
index 0000000..6aea353
--- /dev/null
+++ b/ogc/edr/config/default.json
@@ -0,0 +1,47 @@
+{
+ "server": {
+ "mimetype": "application/json; charset=UTF-8",
+ "encoding": "utf-8",
+ "language": "en-US",
+ "cors": true,
+ "pretty_print": true,
+ "limits": {
+ "default_items": 50,
+ "max_items": 1000,
+ "max_distance_x": 999999999999,
+ "max_distance_y": 999999999999,
+ "max_distance_units": "km",
+ "on_exceed": "error"
+ },
+ "admin": false,
+ "map": {
+ "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "attribution": "© OpenStreetMap contributors"
+ }
+ },
+ "logging": {
+ "level": "ERROR"
+ },
+ "metadata": {
+ "identification": {
+ "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"
+ },
+ "license": {
+ "name": "Apache 2.0 license",
+ "url": "http://www.apache.org/licenses/LICENSE-2.0"
+ },
+ "provider": {
+ "name": "Creare LLC",
+ "url": "https://github.com/creare-com"
+ },
+ "contact": {
+ "name": "Creare LLC",
+ "url": "https://github.com/creare-com"
+ }
+ }
+}
diff --git a/ogc/edr/edr_api.py b/ogc/edr/edr_api.py
new file mode 100644
index 0000000..7758e1e
--- /dev/null
+++ b/ogc/edr/edr_api.py
@@ -0,0 +1,813 @@
+import json
+import pyproj
+import numpy as np
+import pygeoapi.api
+import pygeoapi.api.environmental_data_retrieval as pygeoedr
+from http import HTTPStatus
+from datetime import datetime, timezone
+from typing import Tuple, List, Dict, Any, Union
+from ogc import podpac as pogc
+from pygeoapi.plugin import load_plugin
+from pygeoapi.util import filter_dict_by_key_value, to_json, get_provider_by_type
+from pygeoapi.api import API, APIRequest
+from pygeoapi.linked_data import jsonldify
+from .edr_provider import EdrProvider
+from .. import settings
+
+
+class EdrAPI:
+ """Used to modify the default responses before returning data to the user."""
+
+ CONFORMANCE_CLASSES = sorted(
+ {
+ "https://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core",
+ "https://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections",
+ "https://www.opengis.net/spec/ogcapi-edr-1/1.1/conf/core",
+ }
+ )
+ SCHEMA_CLASS = "https://schemas.opengis.net/ogcapi/edr/1.1/openapi"
+
+ @jsonldify
+ @staticmethod
+ def landing_page(api: API, request: APIRequest) -> Tuple[dict, int, str]:
+ """Provide the API landing page.
+
+ Parameters
+ ----------
+ api : API
+ The API which handles the request.
+ request : APIRequest
+ The request object.
+
+ Returns
+ -------
+ Tuple[dict, int, str]
+ Headers, HTTP Status, and Content returned as a tuple.
+ """
+ return pygeoapi.api.landing_page(api, request)
+
+ @staticmethod
+ def openapi_(api: API, request: APIRequest) -> Tuple[dict, int, str]:
+ """Provide the OpenAPI documentation.
+
+ Parameters
+ ----------
+ api : API
+ The API which handles the request.
+ request : APIRequest
+ The request object.
+
+ Returns
+ -------
+ Tuple[dict, int, str]
+ Headers, HTTP Status, and Content returned as a tuple.
+ """
+ html_path = "openapi/redoc.html" if request._args.get("ui") == "redoc" else "openapi/swagger.html"
+ headers = request.get_response_headers(**api.api_headers)
+
+ if request.format == pygeoapi.api.F_HTML:
+ data = {"openapi-document-path": f"{api.base_url}/openapi"}
+ content = pygeoapi.api.render_j2_template(
+ api.tpl_config, api.config["server"]["templates"], html_path, data, request.locale
+ )
+
+ return headers, HTTPStatus.OK, content
+
+ headers["Content-Type"] = "application/vnd.oai.openapi+json;version=3.0"
+
+ if isinstance(api.openapi, dict):
+ openapi = EdrAPI._openapi_update(api.openapi)
+ return headers, HTTPStatus.OK, to_json(openapi, api.pretty_print)
+ else:
+ return headers, HTTPStatus.OK, api.openapi
+
+ @staticmethod
+ def conformance(api: API, request: APIRequest) -> Tuple[dict, int, str]:
+ """Provide the conformance definition.
+
+ Parameters
+ ----------
+ api : API
+ The API which handles the request.
+ request : APIRequest
+ The request object.
+
+ Returns
+ -------
+ Tuple[dict, int, str]
+ Headers, HTTP Status, and Content returned as a tuple.
+ """
+ html_path = "conformance.html"
+ conformance = {"conformsTo": list(EdrAPI.CONFORMANCE_CLASSES)}
+
+ headers = request.get_response_headers(**api.api_headers)
+ if request.format == pygeoapi.api.F_HTML:
+ content = pygeoapi.api.render_j2_template(
+ api.tpl_config, api.config["server"]["templates"], html_path, conformance, request.locale
+ )
+
+ return headers, HTTPStatus.OK, content
+
+ return headers, HTTPStatus.OK, to_json(conformance, api.pretty_print)
+
+ @jsonldify
+ @staticmethod
+ def describe_collections(api: API, request: APIRequest, dataset: str | None = None) -> Tuple[dict, int, str]:
+ """Provide the collection/collections metadata.
+
+ Overrides default functionality to append additional metadata to collections.
+
+ Parameters
+ ----------
+ api : API
+ The API which handles the request.
+ request : APIRequest
+ The request object.
+ dataset : str | None, optional
+ The dataset (collection) to be described or None for all collections, by default None.
+
+ Returns
+ -------
+ Tuple[dict, int, str]
+ Headers, HTTP Status, and Content returned as a tuple.
+ """
+ headers, status, content = pygeoapi.api.describe_collections(api, request, dataset)
+ if request.format != pygeoapi.api.F_JSON or status != HTTPStatus.OK:
+ return headers, status, content
+
+ collection_description = json.loads(content)
+ collection_configuration = filter_dict_by_key_value(api.config["resources"], "type", "collection")
+ collections = [collection_description] if dataset is not None else collection_description.get("collections", [])
+
+ for collection in collections:
+ collection_id = collection["id"]
+ provider = get_provider_by_type(collection_configuration[collection_id]["providers"], "edr")
+ provider_plugin = load_plugin("provider", provider)
+ provider_parameters = provider_plugin.get_fields()
+ 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["data_queries"] = collection_without_queryables["data_queries"]
+
+ 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 = {
+ "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", {})
+ collection["data_queries"][query_type]["link"]["variables"] = variables | data_query_additions
+
+ for parameter in collection["parameter_names"]:
+ collection["parameter_names"][parameter] = collection["parameter_names"][parameter] | {
+ "description": provider_parameters[parameter].get("description", "")
+ }
+
+ return headers, status, to_json(collection_description, api.pretty_print)
+
+ @staticmethod
+ def get_collection_edr_instances(
+ api: API, request: APIRequest, dataset: str, instance_id: str | None = None
+ ) -> Tuple[dict, int, str]:
+ """Provide the instance/instances metadata.
+
+ Overrides default functionality to append additional metadata to instances.
+
+ Parameters
+ ----------
+ api : API
+ The API which handles the request.
+ request : APIRequest
+ The request object.
+ dataset : str
+ The dataset (collection) to be described.
+ instance_id : str | None, optional
+ The instance to be described or None for all instances, by default None.
+
+ Returns
+ -------
+ Tuple[dict, int, str]
+ Headers, HTTP Status, and Content returned as a tuple.
+ """
+ headers, status, content = pygeoedr.get_collection_edr_instances(api, request, dataset, instance_id)
+ if request.format != pygeoapi.api.F_JSON or status != HTTPStatus.OK:
+ return headers, status, content
+
+ instance_description = json.loads(content)
+ collection_configuration = filter_dict_by_key_value(api.config["resources"], "type", "collection")
+ provider = get_provider_by_type(collection_configuration[dataset]["providers"], "edr")
+ provider_plugin = load_plugin("provider", provider)
+ provider_parameters = provider_plugin.get_fields()
+ instances = [instance_description] if instance_id is not None else instance_description.get("instances", [])
+ collection_layers = EdrProvider.get_layers(provider["base_url"], dataset)
+
+ for instance in instances:
+ instance_id = instance["id"]
+ instance["extent"] = EdrAPI._generate_extents(collection_layers, instance_id)
+ instance["output_formats"] = collection_configuration[dataset].get("output_formats", [])
+
+ 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 = {
+ "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", {})
+ instance["data_queries"][query_type]["link"]["variables"] = variables | data_query_additions
+
+ instance["parameter_names"] = EdrAPI._instance_parameters(
+ collection_layers, provider_parameters, instance["id"]
+ )
+
+ return headers, status, to_json(instance_description, api.pretty_print)
+
+ @staticmethod
+ def get_collection_edr_query(
+ api: API,
+ request: APIRequest,
+ dataset: str,
+ instance: str | None,
+ query_type: str,
+ location_id: str | None = None,
+ ) -> Tuple[dict, int, str]:
+ """Query the collection or instance.
+
+ Parameters
+ ----------
+ api : API
+ The API which handles the request.
+ request : APIRequest
+ The request object.
+ dataset : str
+ The dataset (collection) to be queried.
+ instance_id : str | None
+ The instance to be queried or None if querying a collection.
+ query_type : str
+ The query type.
+ location_id : str | None, optional
+ Location identifier for location queries or None, by default None.
+
+ Returns
+ -------
+ Tuple[dict, int, str]
+ Headers, HTTP Status, and Content returned as a tuple.
+ """
+ return pygeoedr.get_collection_edr_query(api, request, dataset, instance, query_type, location_id)
+
+ @staticmethod
+ def _temporal_extents(times: List[Union[np.datetime64, datetime]], trs: str | None) -> Dict[str, Any]:
+ """Get the temporal extents for the provided times and reference system.
+
+ Parameters
+ ----------
+ times : List[Union[np.datetime64, datetime]]
+ Times used to create the temporal extent.
+ trs : str | None
+ The reference system for the times.
+
+ Returns
+ -------
+ Dict[str, Any]
+ The temporal extent object or an empty dictionary..
+ """
+ iso_times = []
+ for time in sorted(times):
+ dt = time.astype("datetime64[ms]").astype(datetime) if isinstance(time, np.datetime64) else time
+ if dt.tzinfo is None:
+ time_utc = dt.replace(tzinfo=timezone.utc)
+ else:
+ 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]],
+ "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]:
+ """Get the spatial extents for the provided bbox and reference system.
+
+ Parameters
+ ----------
+ bbox : List[float]
+ Bounding box used to create the spatial extent.
+ crs : str | None
+ The reference system for the bounding box.
+
+ Returns
+ -------
+ Dict[str, Any]
+ The spatial extent object.
+ """
+ return {
+ "spatial": {
+ "bbox": bbox,
+ **({"crs": crs} if crs is not None else {}),
+ }
+ }
+
+ @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 settings.EDR_TIME_INSTANCE_DIMENSION in coordinates.udims:
+ instance_datetime = np.datetime64(instance)
+ instance_coordinates = coordinates.select(
+ {settings.EDR_TIME_INSTANCE_DIMENSION: [instance_datetime, instance_datetime]}
+ )
+ selected_time_coordinates = instance_coordinates["time"].coordinates
+ time_range.update(selected_time_coordinates)
+ elif not instance and settings.EDR_TIME_INSTANCE_DIMENSION 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
+ ) -> Dict[str, Any]:
+ """Get the parameter metadata for the specific instance provided.
+
+ Parameters
+ ----------
+ collection_layers : List[pogc.Layer]
+ The layers available in the collection.
+ provider_parameters: Dict[str, Any]
+ The metadata for all available parameters in the collection from provider fields.
+ instance: str
+ The instance to determine parameters for.
+
+ Returns
+ -------
+ Dict[str, Any]
+ The metadata for available parameters in the instance.
+ """
+ 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():
+ instance_parameters[key] = {
+ "id": key,
+ "type": "Parameter",
+ "name": value["title"],
+ "observedProperty": {
+ "label": {"id": key, "en": value["title"]},
+ },
+ "description": value["description"],
+ "unit": {
+ "label": {"en": value["title"]},
+ "symbol": {
+ "value": value["x-ogc-unit"],
+ "type": "http://www.opengis.net/def/uom/UCUM/",
+ },
+ },
+ }
+ 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.
+
+ Parameters
+ ----------
+ api : Dict[str, Any]
+ The OpenAPI definition to be updated.
+
+ Returns
+ -------
+ Dict[str, Any]
+ The customized OpenAPI definition.
+ """
+ server_tag = "Server"
+ collection_tag = "Collection Information"
+ instance_tag = "Instance Information"
+ query_tag = "Query"
+
+ resource_not_found_error = {"description": "Resource not found."}
+ internal_application_error = {
+ "description": "Internal application error",
+ "content": {
+ "application/xml": {
+ "schema": {
+ "type": "object",
+ "format": "xml",
+ "xml": {"name": "ExceptionReport"},
+ },
+ "example": (
+ ''
+ ""
+ ''
+ "Internal application error"
+ ""
+ ""
+ ),
+ }
+ },
+ }
+
+ query_base_parameters = [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/crs.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/datetime.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/parameter-name.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/z.yaml"},
+ ]
+ query_responses = {
+ "200": {
+ "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/queries/200.yaml",
+ },
+ "400": {
+ "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/queries/400.yaml",
+ },
+ "404": resource_not_found_error,
+ "default": internal_application_error,
+ }
+
+ openapi = {}
+ openapi["openapi"] = api.get("openapi")
+ openapi["info"] = api.get("info")
+ openapi["servers"] = api.get("servers")
+ openapi["tags"] = [server_tag, collection_tag, instance_tag, query_tag]
+ openapi["paths"] = {
+ "/": {
+ "get": {
+ "summary": "Landing Page",
+ "description": "Landing page of the API.",
+ "tags": [server_tag],
+ "operationId": "getLandingPage",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"},
+ ],
+ "responses": {
+ "200": {
+ "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/core/landingPage.yaml",
+ },
+ "default": internal_application_error,
+ },
+ },
+ },
+ "/api": {
+ "get": {
+ "summary": "Capabilities of the API.",
+ "description": "API",
+ "tags": [server_tag],
+ "operationId": "getApi",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"},
+ ],
+ "responses": {
+ "200": {
+ "description": "API capabilities",
+ },
+ "default": internal_application_error,
+ },
+ },
+ },
+ "/conformance": {
+ "get": {
+ "summary": "Conformance classes defining standard compliance of the API.",
+ "description": "Conformance Classes",
+ "tags": [server_tag],
+ "operationId": "getConformance",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"},
+ ],
+ "responses": {
+ "200": {
+ "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/core/conformance.yaml",
+ },
+ "default": internal_application_error,
+ },
+ },
+ },
+ "/collections": {
+ "get": {
+ "summary": "Collection information for all available collections.",
+ "description": "Collections",
+ "tags": [collection_tag],
+ "operationId": "getCollections",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"},
+ ],
+ "responses": {
+ "200": {
+ "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/collections/collections.yaml",
+ },
+ "default": internal_application_error,
+ },
+ },
+ },
+ "/collections/{collectionId}": {
+ "get": {
+ "summary": "Collection information for a single collection.",
+ "description": "Collection",
+ "tags": [collection_tag],
+ "operationId": "getCollection",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"},
+ ],
+ "responses": {
+ "200": {
+ "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/collections/collection.yaml",
+ },
+ "404": resource_not_found_error,
+ "default": internal_application_error,
+ },
+ },
+ },
+ "/collections/{collectionId}/area": {
+ "get": {
+ "summary": "Query a collection for an area.",
+ "description": "Collection Area Query",
+ "tags": [query_tag],
+ "operationId": "getCollectionArea",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/areaCoords.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-x.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-y.yaml"},
+ *query_base_parameters,
+ ],
+ "responses": query_responses,
+ },
+ },
+ "/collections/{collectionId}/cube": {
+ "get": {
+ "summary": "Query a collection for a cube.",
+ "description": "Collection Cube Query",
+ "tags": [query_tag],
+ "operationId": "getCollectionCube",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/bbox.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-x.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-y.yaml"},
+ *query_base_parameters,
+ ],
+ "responses": query_responses,
+ },
+ },
+ "/collections/{collectionId}/position": {
+ "get": {
+ "summary": "Query a collection for a position.",
+ "description": "Collection Position Query",
+ "tags": [query_tag],
+ "operationId": "getCollectionPosition",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/positionCoords.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"},
+ *query_base_parameters,
+ ],
+ "responses": query_responses,
+ },
+ },
+ "/collections/{collectionId}/instances/": {
+ "get": {
+ "summary": "Instance information for all available instances in a collection.",
+ "description": "Collection Instances",
+ "tags": [instance_tag],
+ "operationId": "getCollectionInstances",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"},
+ ],
+ "responses": {
+ "200": {
+ "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/queries/instances.yaml",
+ },
+ "404": resource_not_found_error,
+ "default": internal_application_error,
+ },
+ },
+ },
+ "/collections/{collectionId}/instances/{instanceId}": {
+ "get": {
+ "summary": "Instance information for a single instance in a collection.",
+ "description": "Collection Instance",
+ "tags": [instance_tag],
+ "operationId": "getCollectionInstance",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/queries/instanceId.yaml"},
+ ],
+ "responses": {
+ "200": {
+ "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/queries/instances.yaml",
+ },
+ "404": resource_not_found_error,
+ "default": internal_application_error,
+ },
+ },
+ },
+ "/collections/{collectionId}/instances/{instanceId}/area": {
+ "get": {
+ "summary": "Query a collection instance for an area.",
+ "description": "Instance Area Query",
+ "tags": [query_tag],
+ "operationId": "getCollectionInstanceArea",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/areaCoords.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/queries/instanceId.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-x.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-y.yaml"},
+ *query_base_parameters,
+ ],
+ "responses": query_responses,
+ },
+ },
+ "/collections/{collectionId}/instances/{instanceId}/cube": {
+ "get": {
+ "summary": "Query a collection instance for a cube.",
+ "description": "Instance Cube Query",
+ "tags": [query_tag],
+ "operationId": "getCollectionInstanceCube",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/bbox.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/queries/instanceId.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-x.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-y.yaml"},
+ *query_base_parameters,
+ ],
+ "responses": query_responses,
+ },
+ },
+ "/collections/{collectionId}/instances/{instanceId}/position": {
+ "get": {
+ "summary": "Query a collection instance for a position.",
+ "description": "Instance Position Query",
+ "tags": [query_tag],
+ "operationId": "getCollectionInstancePosition",
+ "parameters": [
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/positionCoords.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"},
+ {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/queries/instanceId.yaml"},
+ *query_base_parameters,
+ ],
+ "responses": query_responses,
+ },
+ },
+ }
+
+ return openapi
diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py
new file mode 100644
index 0000000..2d9716f
--- /dev/null
+++ b/ogc/edr/edr_config.py
@@ -0,0 +1,147 @@
+import os
+import json
+import logging
+from typing import List, Dict, Any
+from ogc import podpac as pogc
+from .. import settings
+
+
+class EdrConfig:
+ """Defines the configuration for the pygeoapi based server.
+
+ This configuration is used to replace the typical YAML based configurations in order to provide dynamic properties.
+ """
+
+ @staticmethod
+ def get_configuration(base_url: str, layers: List[pogc.Layer]) -> Dict[str, Any]:
+ """Generate the configuration for the API.
+
+ Parameters
+ ----------
+ base_url : str
+ The base URL for the EDR endpoints.
+ layers : List[pogc.Layer]
+ The layers which define the data sources for the EDR server.
+
+ Returns
+ -------
+ Dict[str, Any]
+ The configuration for the API as a dictionary.
+ """
+ configuration_path = settings.EDR_CONFIGURATION_PATH
+ if configuration_path is None:
+ configuration_path = os.path.abspath(os.path.join(os.path.dirname(__file__) + "/config/default.json"))
+
+ configuration = {}
+ with open(configuration_path) as f:
+ configuration = json.load(f)
+
+ # Add default static files with an absolute path
+ server = configuration.get("server", {})
+ configuration["server"] = server | {
+ "templates": {
+ "path": os.path.abspath(os.path.join(os.path.dirname(__file__) + "/templates/")),
+ "static": os.path.abspath(os.path.join(os.path.dirname(__file__) + "/static/")),
+ }
+ }
+ configuration["server"]["url"] = base_url
+
+ # Add the data resources and provider information
+ resources = configuration.get("resources", {})
+ configuration["resources"] = resources | EdrConfig._resources_definition(base_url, layers)
+
+ # Force the log level based on the configuration as it is loaded, otherwise it is ignored
+ if configuration.get("logging", {}).get("level"):
+ api_logger = logging.getLogger("pygeoapi")
+ api_logger.setLevel(configuration["logging"]["level"])
+
+ return configuration
+
+ @staticmethod
+ def _resources_definition(base_url: str, layers: List[pogc.Layer]) -> Dict[str, Any]:
+ """Define resource related data for the configuration.
+
+ The resources dictionary holds the information needed to generate the collections.
+ Each group is mapped to a collection with the layers in the group forming the collection parameters.
+ The custom provider is specified with a data value of the group name.
+ This allows for the provider to generate the collection data for each group.
+
+ Parameters
+ ----------
+ base_url : str
+ The base URL used as an identifier for the given layers.
+ layers : List[pogc.Layer]
+ The layers which define the data sources for the EDR server.
+
+ Returns
+ -------
+ Dict[str, Any]
+ The resources configuration for the API as a dictionary.
+ """
+
+ resources = {}
+ groups = {layer.group for layer in layers}
+
+ # Generate collection resources based on groups
+ for group_name in groups:
+ resource = {
+ group_name: {
+ "type": "collection",
+ "visibility": "default",
+ "title": group_name,
+ "description": f"Collection of data related to {group_name}",
+ "keywords": ["podpac"],
+ "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": [
+ {
+ "type": "edr",
+ "default": True,
+ "name": "ogc.edr.edr_provider.EdrProvider",
+ "data": group_name,
+ "base_url": base_url,
+ "crs": list(settings.EDR_CRS.keys()),
+ "format": {
+ "name": settings.GEOTIFF,
+ "mimetype": "image/tiff",
+ },
+ }
+ ],
+ "formatters": [
+ {
+ "name": "ogc.edr.edr_formatter.GeoTiffFormatter",
+ "mimetype": "image/tiff",
+ },
+ {
+ "name": "ogc.edr.edr_formatter.CoverageJsonFormatter",
+ "mimetype": "application/prs.coverage+json",
+ },
+ ],
+ }
+ }
+ resources.update(resource)
+
+ return resources
+
+ @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_formatter.py b/ogc/edr/edr_formatter.py
new file mode 100644
index 0000000..104d570
--- /dev/null
+++ b/ogc/edr/edr_formatter.py
@@ -0,0 +1,69 @@
+from pygeoapi.formatter.base import BaseFormatter
+from pygeoapi.util import to_json
+
+
+class BaseEdrFormatter(BaseFormatter):
+ """Base formatter for EDR data."""
+
+ def __init__(self, formatter_def: dict):
+ """Initialize the formatter.
+
+ Parameters
+ ----------
+ formatter_def : dict
+ The formatter definition.
+ """
+
+ super().__init__(formatter_def)
+ self.mimetype = formatter_def["mimetype"]
+
+ def write(self, options: dict | None = None, data: dict | None = None) -> str:
+ """Generate data in the specified format.
+
+ Parameters
+ ----------
+ options : dict, optional
+ Formatting options, by default None.
+ data : dict | None, optional
+ Dictionary representation of the data, by default None.
+
+ Returns
+ -------
+ str
+ String representation of the data.
+ """
+ return to_json(data, True) if data is not None else ""
+
+
+class GeoTiffFormatter(BaseEdrFormatter):
+ """Formatter for GeoTIFF data. Defined for format link information to be populated."""
+
+ def __init__(self, formatter_def: dict):
+ """Initialize the formatter.
+
+ Parameters
+ ----------
+ formatter_def : dict
+ The formatter definition.
+ """
+
+ super().__init__(formatter_def)
+ self.f = "geotiff"
+ self.extension = "tiff"
+
+
+class CoverageJsonFormatter(BaseEdrFormatter):
+ """Formatter for CoverageJSON data. Defined for format link information to be populated."""
+
+ def __init__(self, formatter_def: dict):
+ """Initialize the formatter.
+
+ Parameters
+ ----------
+ formatter_def : dict
+ The formatter definition.
+ """
+
+ super().__init__(formatter_def)
+ self.f = "coveragejson"
+ self.extension = "json"
diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py
new file mode 100644
index 0000000..4167200
--- /dev/null
+++ b/ogc/edr/edr_provider.py
@@ -0,0 +1,1122 @@
+import json
+import tempfile
+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
+from ogc import podpac as pogc
+import podpac
+
+from .. import settings
+
+
+class EdrProvider(BaseEDRProvider):
+ """Custom provider to be used with layer data sources."""
+
+ _layers_dict = defaultdict(list)
+ _extra_args = defaultdict()
+
+ @classmethod
+ def set_extra_query_args(cls, args: Dict[str, Any]):
+ """Set the extra arguments which will be available to the provider on a request.
+
+ Parameters
+ ----------
+ args : Dict[str, Any]
+ The extra arguments which are not included in the default requests.
+ """
+ cls._extra_args = args
+
+ @classmethod
+ def set_layers(cls, base_url: str, layers: List[pogc.Layer]):
+ """Set the layer resources which will be available to the provider.
+
+ Parameters
+ ----------
+ base_url : str
+ The base URL that the layers are available on.
+ layers : List[pogc.Layer]
+ The layers which the provider will have access to.
+ """
+ cls._layers_dict[base_url] = layers
+
+ @classmethod
+ def get_layers(cls, base_url: str, group: str | None = None) -> List[pogc.Layer]:
+ """Get the layer resources for a specific base URL and group.
+
+ Parameters
+ ----------
+ base_url : str
+ The base URL for the layers.
+ group : str | None, optional
+ Optional group to filter layers, by default None.
+
+
+ Returns
+ -------
+ List[pogc.Layer]
+ The layers associated with the base URL.
+ """
+ layers = cls._layers_dict.get(base_url, [])
+ if group is not None:
+ return [layer for layer in layers if layer.group.lower() == group.lower()]
+ 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()
+ if coordinates is not None and settings.EDR_TIME_INSTANCE_DIMENSION not in coordinates.udims:
+ return True
+ return False
+
+ def __init__(self, provider_def: Dict[str, Any]):
+ """Construct the provider using the provider definition.
+
+ Parameters
+ ----------
+ provider_def : Dict[str, Any]
+ The provider configuration definition.
+
+ Raises
+ ------
+ ProviderConnectionError
+ Raised if the specified collection is not found within any layers.
+ ProviderConnectionError
+ Raised if the provider does not specify any base URL.
+ """
+ super().__init__(provider_def)
+ collection_id = provider_def.get("data")
+ if collection_id is None:
+ raise ProviderConnectionError("Data not found.")
+
+ self.collection_id = str(collection_id)
+
+ self.base_url = provider_def.get("base_url", "")
+ if not self.base_url:
+ raise ProviderConnectionError("Valid URL identifier not found for the data.")
+
+ @property
+ def parameters(self) -> Dict[str, pogc.Layer]:
+ """The parameters which are defined in a given collection.
+
+ The parameters map to the layers which are a part of the group, with keys of the layer identifiers.
+
+ Returns
+ -------
+ Dict[str, pogc.Layer]
+ The parameters as a dictionary of layer identifiers and layer objects.
+ """
+ return {layer.identifier: layer for layer in self.get_layers(self.base_url, self.collection_id)}
+
+ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs):
+ """Handle the requests to the EDR server at the specified requested coordinates.
+ The coordinates are expected to be latitude and longitude values determined by the specific query function.
+
+ Parameters
+ ----------
+ requested_coordinates : podpac.Coordinates
+ The coordinates for evaluation, it is expected that the coordinates passed in only hold lat and lon.
+ instance : str
+ The time instance for the request.
+ select_properties : List[str]
+ The selected properties (parameters) for the request.
+ format_ : str
+ The requested output format of the data.
+ datetime_ : str
+ 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
+ -------
+ Any
+ Coverage data as a dictionary of CoverageJSON or native format.
+
+ Raises
+ ------
+ ProviderInvalidQueryError
+ Raised if an invalid instance is provided.
+ ProviderInvalidQueryError
+ Raised if an invalid parameter is provided.
+ ProviderInvalidQueryError
+ Raised if a datetime string is provided but cannot be interpreted.
+ ProviderInvalidQueryError
+ Raised if an altitude string is provided but cannot be interpreted.
+ ProviderInvalidQueryError
+ Raised if a GeoTIFF request includes multiple time bands.
+ ProviderInvalidQueryError
+ Raised if a GeoTIFF request includes multiple vertical bands.
+ ProviderInvalidQueryError
+ Raised if native coordinates could not be found.
+ ProviderInvalidQueryError
+ Raised if the request queries for native coordinates exceeding the max allowable size.
+ ProviderInvalidQueryError
+ Raised if no parameters could not be evaluated.
+ """
+ instance = kwargs.get("instance")
+ requested_parameters = kwargs.get("select_properties")
+ output_format = kwargs.get("format_")
+ datetime_arg = kwargs.get("datetime_")
+ z_arg = kwargs.get("z")
+ resolution_x = kwargs.get("resolution-x")
+ resolution_y = kwargs.get("resolution-y")
+
+ instance = self.validate_instance(instance)
+ requested_parameters = self.validate_parameters(requested_parameters)
+ resolution_x, resolution_y = self.validate_resolution(resolution_x, resolution_y)
+
+ crs = self.interpret_crs(requested_coordinates.crs)
+ available_times = self.get_datetimes(list(self.parameters.values()), instance)
+ available_altitudes = self.get_altitudes(list(self.parameters.values()))
+ time_coords = self.interpret_time_coordinates(
+ available_times, datetime_arg, instance, requested_coordinates.crs
+ )
+ altitude_coords = self.interpret_altitude_coordinates(available_altitudes, z_arg, requested_coordinates.crs)
+
+ if time_coords is not None:
+ self.check_query_condition(
+ 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])
+ if altitude_coords is not None:
+ self.check_query_condition(
+ len(altitude_coords["alt"].coordinates) > 1 and output_format == settings.GEOTIFF.lower(),
+ "GeoTIFF output currently only supports single altitude requests.",
+ )
+ requested_coordinates = podpac.coordinates.merge_dims([altitude_coords, requested_coordinates])
+
+ # Handle defining native coordinates for the query, these should match between each layer
+ 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, resolution_lat, resolution_lon
+ )
+
+ self.check_query_condition(
+ bool(requested_native_coordinates.size > settings.MAX_GRID_COORDS_REQUEST_SIZE),
+ "Coordinates size must be less than %d" % settings.MAX_GRID_COORDS_REQUEST_SIZE,
+ )
+
+ dataset = {}
+ for requested_parameter, layer in requested_parameters.items():
+ units_data_array = EdrProvider.evaluate_layer(requested_native_coordinates, layer)
+ if units_data_array is not None:
+ dataset[requested_parameter] = units_data_array
+
+ self.check_query_condition(len(dataset) == 0, "No matching parameters found.")
+
+ 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)
+
+ return self.to_geotiff_response(dataset, self.collection_id)
+
+ def position(self, **kwargs):
+ """Handles requests for the position query type.
+
+ Parameters
+ ----------
+ wkt : shapely.geometry
+ WKT geometry
+ format_ : str
+ The requested output format of the data.
+
+ Returns
+ -------
+ Any
+ Coverage data as a dictionary of CoverageJSON or native format.
+
+ Raises
+ ------
+ ProviderInvalidQueryError
+ Raised if an invalid output format is provided.
+ ProviderInvalidQueryError
+ Raised if the wkt string is not provided.
+ ProviderInvalidQueryError
+ Raised if the wkt string is an unknown type.
+ """
+ lat, lon = [], []
+ wkt = kwargs.get("wkt")
+ crs = self._extra_args.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."
+ raise ProviderInvalidQueryError(msg, user_msg=msg)
+ elif wkt.geom_type == "Point":
+ lon, lat = EdrProvider.crs_converter([wkt.x], [wkt.y], crs)
+ else:
+ msg = "Unknown WKT string type for the position query (use Point)."
+ raise ProviderInvalidQueryError(msg, user_msg=msg)
+
+ requested_coordinates = podpac.Coordinates([lat, lon], dims=["lat", "lon"], crs=crs)
+
+ return self.handle_query(requested_coordinates, **kwargs)
+
+ def cube(self, **kwargs):
+ """Handles requests for the cube query type.
+
+ Parameters
+ ----------
+ bbox : List[float]
+ Bbox geometry (for cube queries)
+ format_ : str
+ The requested output format of the data.
+
+ Returns
+ -------
+ Any
+ Coverage data as a dictionary of CoverageJSON or native format.
+
+ Raises
+ ------
+ ProviderInvalidQueryError
+ Raised if an invalid output format is provided.
+ ProviderInvalidQueryError
+ Raised if the bounding box is invalid.
+ """
+ bbox = kwargs.get("bbox")
+ 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")
+ kwargs["resolution-y"] = self._extra_args.get("resolution-y")
+
+ if not isinstance(bbox, List) or (len(bbox) != 4 and len(bbox) != 6):
+ msg = (
+ "Invalid bounding box provided, "
+ "expected bounding box of (minx, miny, maxx, maxy) or (minx, miny, minz, maxx, maxy, maxz)."
+ )
+ raise ProviderInvalidQueryError(msg, user_msg=msg)
+
+ if len(bbox) == 6:
+ xmin, ymin, zmin, xmax, ymax, zmax = bbox
+ # Set the z argument if not specified using a closed interval from the bounding box data
+ if kwargs.get("z") is None:
+ kwargs["z"] = f"{zmin}/{zmax}"
+ else:
+ xmin, ymin, xmax, ymax = bbox
+
+ lon, lat = EdrProvider.crs_converter([xmin, xmax], [ymin, ymax], crs)
+ requested_coordinates = podpac.Coordinates([lat, lon], dims=["lat", "lon"], crs=crs)
+
+ return self.handle_query(requested_coordinates, **kwargs)
+
+ def area(self, **kwargs):
+ """Handles requests for the area query type.
+
+ Parameters
+ ----------
+ wkt : shapely.geometry
+ WKT geometry
+ format_ : str
+ The requested output format of the data.
+
+ Returns
+ -------
+ Any
+ Coverage data as a dictionary of CoverageJSON or native format.
+
+ Raises
+ ------
+ ProviderInvalidQueryError
+ Raised if an invalid output format is provided.
+ ProviderInvalidQueryError
+ Raised if the wkt string is not provided.
+ ProviderInvalidQueryError
+ Raised if the wkt string is an unknown type.
+ """
+ lat, lon = [], []
+ wkt = kwargs.get("wkt")
+ 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")
+ kwargs["resolution-y"] = self._extra_args.get("resolution-y")
+
+ if not isinstance(wkt, BaseGeometry):
+ msg = "Invalid WKT string provided for the area query."
+ raise ProviderInvalidQueryError(msg, user_msg=msg)
+ elif wkt.geom_type == "Polygon":
+ lon, lat = EdrProvider.crs_converter(wkt.exterior.xy[0], wkt.exterior.xy[1], crs)
+ else:
+ msg = "Unknown WKT string type for the area query (use Polygon)."
+ raise ProviderInvalidQueryError(msg, user_msg=msg)
+
+ requested_coordinates = podpac.Coordinates([lat, lon], dims=["lat", "lon"], crs=crs)
+
+ return self.handle_query(requested_coordinates, **kwargs)
+
+ def get_instance(self, instance: str) -> str | None:
+ """Validate instance identifier.
+
+ Parameters
+ ----------
+ instance : str
+ The instance identifier to validate.
+
+ Returns
+ -------
+ str
+ The instance identifier if valid, otherwise returns None.
+ """
+ return instance if instance in self.instances() else None
+
+ def instances(self, **kwargs) -> List[str]:
+ """The instances in the collection.
+
+ Returns
+ -------
+ List[str]
+ The instances available in the collection.
+ """
+ instances = set()
+ collection_layers = self.get_layers(self.base_url, self.collection_id)
+ for layer in collection_layers:
+ instances.update(layer.time_instances())
+ return list(instances)
+
+ def get_fields(self) -> Dict[str, Any]:
+ """The observed property fields (parameters) in the collection.
+
+ Returns
+ -------
+ Dict[str, Any]
+ The fields based on the available parameters.
+ """
+ fields = {}
+ for parameter_key, layer in self.parameters.items():
+ fields[parameter_key] = {
+ "type": "number",
+ "title": parameter_key,
+ "description": layer.abstract,
+ "x-ogc-unit": layer.get_units(),
+ }
+ return fields
+
+ 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.
+ If the provided output format is invalid, raise an error.
+
+ Parameters
+ ----------
+ 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
+ -------
+ str
+ Output format string.
+
+ Raises
+ ------
+ ProviderInvalidQueryError
+ Raised if the provided output format is invalid.
+ """
+ if output_format is None:
+ return settings.EDR_QUERY_DEFAULTS.get(query_type, "")
+
+ 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()
+
+ def validate_resolution(self, resolution_x: str | None, resolution_y: str | None) -> Tuple[int, int]:
+ """Validate the resolutions and return the values as integers.
+
+ If no resolution is provided in a specific direction a zero value should be used to indicate native resolution.
+
+ Parameters
+ ----------
+ resolution_x : str | None
+ The resolution in the x-direction or None.
+ resolution_y : str | None
+ The resolution in the y-direction or None.
+
+ Returns
+ -------
+ Tuple[int, int]
+ Resolution x and y as integers.
+
+ Raises
+ ------
+ ProviderInvalidQueryError
+ Raised if either of the provided resolutions is invalid.
+ """
+ valid_resolutions = True
+ validated_resolution_x = 0
+ validated_resolution_y = 0
+ try:
+ validated_resolution_x = int(0 if resolution_x is None else resolution_x)
+ validated_resolution_y = int(0 if resolution_y is None else resolution_y)
+ valid_resolutions = validated_resolution_x >= 0 and validated_resolution_y >= 0
+ except ValueError:
+ valid_resolutions = False
+
+ if not valid_resolutions:
+ msg = "Invalid resolution provided, expected positive integer."
+ raise ProviderInvalidQueryError(msg, user_msg=msg)
+
+ return validated_resolution_x, validated_resolution_y
+
+ def validate_instance(self, instance: str | None) -> str | None:
+ """Validate the instance for a query.
+
+ If None provided, the collection is being queried.
+ If the instance is invalid, raise an error.
+
+ Parameters
+ ----------
+ instance : str | None
+ The instance which needs to be validated.
+
+ Returns
+ -------
+ str | None
+ The validated instance or None if the collection is being queried.
+
+ Raises
+ ------
+ ProviderInvalidQueryError
+ Raised if the provided instance is invalid.
+ """
+ if instance is None:
+ return None
+
+ if instance not in self.instances():
+ msg = "Invalid instance provided."
+ raise ProviderInvalidQueryError(msg, user_msg=msg)
+
+ return instance
+
+ def validate_parameters(self, parameters: List[str] | None) -> Dict[str, pogc.Layer]:
+ """Validate the parameters for a query.
+
+ If None provided or an list is empty, return all parameters.
+ If the provided parameter list is invalid, raise an error.
+
+ Parameters
+ ----------
+ parameters : List[str] | None
+ The specified parameters for a query.
+
+ Returns
+ -------
+ Dict[str, pogc.Layer]
+ The validated parameters dictionary containing associated layers.
+
+ Raises
+ ------
+ ProviderInvalidQueryError
+ Raised if the provided parameters are invalid.
+ """
+ if parameters is None or len(parameters) == 0:
+ return self.parameters
+
+ parameters_lower = [param.lower() for param in parameters]
+ parameters_filtered = {
+ key: value
+ for key, value in self.parameters.items()
+ if key.lower() in parameters_lower and value is not None
+ }
+ if len(parameters_filtered) != len(parameters):
+ msg = "Invalid parameters provided."
+ raise ProviderInvalidQueryError(msg, user_msg=msg)
+
+ return parameters_filtered
+
+ @staticmethod
+ def evaluate_layer(requested_coordinates: podpac.Coordinates, layer: pogc.Layer) -> podpac.UnitsDataArray | None:
+ """Evaluate a layer using the requested coordinates.
+
+ Parameters
+ ----------
+ requested_coordinates : podpac.Coordinates
+ The requested coordinates for the evaluation.
+ layer : pogc.Layer
+ The layer to evaluate.
+
+ Returns
+ -------
+ podpac.UnitsDataArray
+ The units data array returned from evaluation or None if the node was not found.
+ """
+ coordinates = layer.get_coordinates()
+ layer_requested_coordinates = requested_coordinates
+ units_data_array = None
+ if coordinates is None:
+ return units_data_array
+
+ layer_has_instances = settings.EDR_TIME_INSTANCE_DIMENSION in coordinates.udims
+ request_has_instances = settings.EDR_TIME_INSTANCE_DIMENSION in requested_coordinates.udims
+ if layer_has_instances ^ request_has_instances:
+ return units_data_array
+
+ if "time" not in coordinates.udims:
+ layer_requested_coordinates = layer_requested_coordinates.udrop(["time"], ignore_missing=True)
+ if layer.node is not None:
+ units_data_array = layer.node.eval(layer_requested_coordinates)
+ units_data_array = units_data_array.unstack()
+ if settings.EDR_TIME_INSTANCE_DIMENSION in units_data_array.dims:
+ # Use scalar selection to drop the extra dimension
+ units_data_array = units_data_array.sel(
+ {
+ settings.EDR_TIME_INSTANCE_DIMENSION: layer_requested_coordinates[
+ settings.EDR_TIME_INSTANCE_DIMENSION
+ ].coordinates[0]
+ }
+ )
+ units_data_array = units_data_array.drop_vars({settings.EDR_TIME_INSTANCE_DIMENSION})
+ if units_data_array.attrs.get("bounds", None):
+ filtered_bounds = {
+ coord: bnd
+ for coord, bnd in units_data_array.attrs["bounds"].items()
+ if coord in units_data_array.coords.dims
+ }
+ units_data_array.attrs["bounds"] = filtered_bounds
+
+ return units_data_array
+
+ @staticmethod
+ def get_altitudes(layers: List[pogc.Layer]) -> List[float]:
+ """The list of available altitudes for the provided layers.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ The list of layers to determine altitudes for.
+
+ Returns
+ -------
+ List[float]
+ Available altitudes for the providers layers.
+ """
+
+ available_altitudes = set()
+ for layer in layers:
+ coordinates = layer.get_coordinates()
+ if coordinates is not None and "alt" in coordinates.udims:
+ available_altitudes.update(coordinates["alt"].coordinates)
+
+ return list(available_altitudes)
+
+ @staticmethod
+ def get_datetimes(layers: List[pogc.Layer], instance_time: str | None) -> List[np.datetime64]:
+ """The list of available times for the provided layers.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ The list of layers to determine datetimes for.
+ instance_time: str | None
+ The optional instance time to get datetimes for, if not provided collection datetimes are returned.
+ Returns
+ -------
+ List[np.datetime64]
+ Available time values for the provider layers.
+ """
+
+ available_times = set()
+ for layer in layers:
+ coordinates = layer.get_coordinates()
+ if coordinates is not None and "time" in coordinates.udims:
+ if (
+ instance_time in layer.time_instances()
+ and settings.EDR_TIME_INSTANCE_DIMENSION in coordinates.udims
+ ):
+ instance_datetime = np.datetime64(instance_time)
+ instance_coordinates = coordinates.select(
+ {settings.EDR_TIME_INSTANCE_DIMENSION: [instance_datetime, instance_datetime]}
+ )
+ selected_time_coordinates = instance_coordinates["time"].coordinates
+ available_times.update(selected_time_coordinates)
+ elif not instance_time and settings.EDR_TIME_INSTANCE_DIMENSION not in coordinates.udims:
+ available_times.update(coordinates["time"].coordinates)
+
+ return list(available_times)
+
+ @staticmethod
+ def interpret_crs(crs: str | None) -> str:
+ """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.
+
+ Parameters
+ ----------
+ crs : str | None
+ The input CRS id string which needs to be validated/converted.
+
+ Returns
+ -------
+ str
+ WKT CRS string.
+
+ Raises
+ ------
+ ProviderInvalidQueryError
+ Raised if the provided CRS string is unknown.
+ """
+ if crs is None:
+ return pyproj.CRS(settings.crs_84_uri_format).to_wkt() # Pyproj acceptable format
+
+ 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 wkt_crs
+
+ @staticmethod
+ def crs_converter(x: Any, y: Any, crs: str) -> Tuple[Any, Any]:
+ """Convert the X, Y data to Longitude, Latitude data with the provided crs.
+
+ Parameters
+ ----------
+ x : Any
+ X data in any form.
+ y: Any
+ Y data in any form.
+ crs : str
+ The input CRS id string to apply to convert the X,Y data.
+
+ Returns
+ -------
+ Tuple[Any, Any]
+ The X,Y as Longitude/Latitude data.
+ """
+ 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)
+
+ @staticmethod
+ def interpret_altitude_coordinates(
+ available_altitudes: List[float], altitude_string: str | None, crs: str | None
+ ) -> podpac.Coordinates | None:
+ """Interpret the altitude string into altitude coordinates using known formats.
+
+ Specification:
+ single-level = level
+ interval-closed = min-level "/" max-level
+ repeating-interval = "R"number of intervals "/" min-level "/" height to increment by
+ level-list = level1 "," level2 "," level3
+
+ Parameters
+ ----------
+ available_altitudes: List[float]
+ The available altitudes for interpretation.
+ altitude_string : str | None
+ The string representation of the requested altitudes.
+ crs : str
+ The CRS that the coordinates need to match.
+
+ Returns
+ -------
+ podpac.Coordinates | None
+ Altitude coordinates for the request or None if no matching altitudes were found.
+
+ Raises
+ ------
+ ProviderInvalidQueryError
+ Raised if the provided altitude string is invalid.
+ """
+
+ if not altitude_string and len(available_altitudes) == 0:
+ return None
+
+ try:
+ altitudes = None
+ if not altitude_string:
+ altitudes = available_altitudes
+ elif "/" in altitude_string:
+ altitudes_split = altitude_string.split("/")
+ if len(altitudes_split) == 2:
+ minimum = float(altitudes_split[0])
+ maximum = float(altitudes_split[1])
+ altitudes = [alt for alt in available_altitudes if minimum <= alt <= maximum]
+ if len(altitudes_split) == 3:
+ if altitudes_split[0].startswith("R"):
+ altitudes = float(altitudes_split[1]) + np.arange(float(altitudes_split[0][1:])) * float(
+ altitudes_split[2]
+ )
+ else:
+ altitudes = [float(alt) for alt in altitude_string.split(",")]
+ except ValueError:
+ msg = "Invalid vertical level requested."
+ raise ProviderInvalidQueryError(msg, user_msg=msg)
+
+ if altitudes is None:
+ msg = "Invalid vertical level requested."
+ raise ProviderInvalidQueryError(msg, user_msg=msg)
+
+ return podpac.Coordinates([altitudes], dims=["alt"], crs=crs) if altitudes is not None else None
+
+ @staticmethod
+ def interpret_time_coordinates(
+ available_times: List[np.datetime64], time_string: str | None, instance_time: str | None, crs: str | None
+ ) -> podpac.Coordinates | None:
+ """Interpret the time string and instance into time coordinates using known formats.
+
+ Specification:
+ interval-closed = date-time "/" date-time
+ interval-open-start = "../" date-time
+ interval-open-end = date-time "/.."
+ interval = interval-closed / interval-open-start / interval-open-end
+ datetime = date-time / interval
+
+ Parameters
+ ----------
+ available_times: List[np.datetime64]
+ The available times for interpretation.
+ time_string : str | None
+ The string representation of the requested times.
+ instance_time: str | None
+ The string representation of the requested instance time.
+ crs : str
+ The CRS that the coordinates need to match.
+
+ Returns
+ -------
+ podpac.Coordinates | None
+ Time coordinates for the request or None if no matching times were found.
+
+ Raises
+ ------
+ ProviderInvalidQueryError
+ Raised if the provided time string is invalid.
+ """
+
+ if not time_string and len(available_times) == 0:
+ return None
+
+ try:
+ times = None
+ if not time_string:
+ times = available_times
+ elif "/" in time_string:
+ times_split = time_string.split("/")
+ if len(times_split) == 2:
+ minimum = times_split[0]
+ maximum = times_split[1]
+ if minimum == "..":
+ times = [time for time in available_times if time <= np.datetime64(maximum)]
+ elif maximum == "..":
+ times = [time for time in available_times if time >= np.datetime64(minimum)]
+ else:
+ times = [
+ time for time in available_times if np.datetime64(minimum) <= time <= np.datetime64(maximum)
+ ]
+ else:
+ times = [np.datetime64(time_string)]
+ except ValueError:
+ msg = "Invalid datetime requested."
+ raise ProviderInvalidQueryError(msg, user_msg=msg)
+
+ if times is None:
+ msg = "Invalid datetime requested."
+ raise ProviderInvalidQueryError(msg, user_msg=msg)
+
+ if instance_time:
+ return podpac.Coordinates(
+ [times, [instance_time]], dims=["time", settings.EDR_TIME_INSTANCE_DIMENSION], crs=crs
+ )
+
+ return podpac.Coordinates([times], dims=["time"], crs=crs)
+
+ @staticmethod
+ def to_coverage_json(
+ 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.
+
+ The returned object must be serializable, so a temporary file is returned to reference the data.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ 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 desired output file name and data path.
+ """
+
+ # Determine the bounding coordinates, assume they all are the same
+ coordinates = next(iter(dataset.values())).coords
+ x_arr, y_arr = EdrProvider.crs_converter(coordinates["lon"].values, coordinates["lat"].values, crs)
+
+ # Convert numpy array coordinates to a flattened list.
+ 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": {
+ "type": "Domain",
+ "domainType": "Grid",
+ "axes": {
+ "x": {
+ "start": x_arr[0] if len(x_arr) > 0 else None,
+ "stop": x_arr[-1] if len(x_arr) > 0 else None,
+ "num": len(x_arr),
+ },
+ "y": {
+ "start": y_arr[0] if len(y_arr) > 0 else None,
+ "stop": y_arr[-1] if len(y_arr) > 0 else None,
+ "num": len(y_arr),
+ },
+ },
+ "referencing": [
+ {
+ "coordinates": ["x", "y"],
+ "system": {"type": "GeographicCRS", "id": crs},
+ },
+ *(
+ [
+ {
+ "coordinates": ["t"],
+ "system": {
+ "type": "TemporalRS",
+ "calendar": "Gregorian",
+ },
+ }
+ ]
+ if "time" in coordinates.dims
+ else []
+ ),
+ *(
+ [
+ {
+ "coordinates": ["z"],
+ "system": {"type": "VerticalCRS"},
+ }
+ ]
+ if "alt" in coordinates.dims
+ else []
+ ),
+ ],
+ "parameters": {},
+ "ranges": {},
+ },
+ }
+ if "time" in coordinates.dims:
+ coverage_json["domain"]["axes"]["t"] = {
+ "values": [
+ time.astype("datetime64[ms]").astype(datetime).isoformat() + "Z"
+ for time in coordinates["time"].values
+ ]
+ }
+
+ for param, data_array in dataset.items():
+ layer = next((layer for layer in layers if layer.identifier == param), None)
+ if layer is not None:
+ units = layer.get_units()
+ parameter_definition = {
+ param: {
+ "type": "Parameter",
+ "observedProperty": {
+ "label": {
+ "id": param,
+ "en": param,
+ }
+ },
+ "description": layer.abstract,
+ "unit": {
+ "label": {"en": param},
+ "symbol": {
+ "value": units,
+ "type": None,
+ },
+ },
+ }
+ }
+ coverage_json["domain"]["parameters"].update(parameter_definition)
+
+ data = [x if np.isfinite(x) else None for x in data_array.values.flatten()]
+ coverage_json["domain"]["ranges"].update(
+ {
+ param: {
+ "type": "NdArray",
+ "dataType": "float",
+ "axisNames": [dimension_map.get(str(key), str(key)) for key in data_array.coords.keys()],
+ "shape": data_array.shape,
+ "values": data, # Row Major Order
+ }
+ }
+ )
+
+ 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):
+ """Check the provided conditional and raise a ProviderInvalidQueryError if true.
+
+ Parameters
+ ----------
+ conditional : bool
+ The conditional value to check for raising a query error.
+ message : str
+ The message to include if the query error is raised.
+
+ Raises
+ ------
+ ProviderInvalidQueryError
+ Raised if the conditional provided is true.
+ """
+ if conditional:
+ raise ProviderInvalidQueryError(message, user_msg=message)
+
+ @staticmethod
+ 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]
+ Data in an units data array format with matching parameter key.
+ collection_id : str
+ The collection id of the data used in naming the zip file if needed.
+
+ Returns
+ -------
+ Dict[str, Any]
+ 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": named_file.name,
+ "fn": f"{next(iter(dataset.keys()))}.tif",
+ }
+ else:
+ 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(
+ source_coordinates: podpac.Coordinates,
+ target_coordinates: podpac.Coordinates,
+ resolution_lat: int,
+ resolution_lon: int,
+ ) -> podpac.Coordinates:
+ """Find the intersecting latitude and longitude coordinates between the source and target.
+
+ Parameters
+ ----------
+ source_coordinates : podpac.Coordinates
+ The source coordinates to be converted.
+ target_coordinates : podpac.Coordinates
+ The target coordinates to find intersections on.
+ resolution_lat: int
+ The desired resolution in the latitudinal direction, with a zero value using native resolution.
+ resolution_lon: int
+ The desired resolution in the longitudinal direction, with a zero value using native resolution.
+
+ Returns
+ -------
+ 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
+ else np.linspace(
+ np.min(source_coordinates["lat"].coordinates),
+ np.max(source_coordinates["lat"].coordinates),
+ resolution_lat,
+ )
+ )
+ longitudes = (
+ target_coordinates["lon"].coordinates
+ if resolution_lon == 0
+ else np.linspace(
+ np.min(source_coordinates["lon"].coordinates),
+ np.max(source_coordinates["lon"].coordinates),
+ resolution_lon,
+ )
+ )
+
+ # Find intersections with target keeping source crs
+ target_spatial_coordinates = podpac.Coordinates(
+ [latitudes, longitudes], dims=["lat", "lon"], crs=target_coordinates.crs
+ )
+ source_intersection_coordinates = target_spatial_coordinates.intersect(source_coordinates, dims=["lat", "lon"])
+ source_intersection_coordinates = source_intersection_coordinates.transform(source_coordinates.crs)
+ return podpac.coordinates.merge_dims(
+ [source_intersection_coordinates, source_coordinates.udrop(["lat", "lon"], ignore_missing=True)]
+ )
diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py
new file mode 100644
index 0000000..b03435b
--- /dev/null
+++ b/ogc/edr/edr_routes.py
@@ -0,0 +1,326 @@
+import os
+import mimetypes
+import json
+import traitlets as tl
+import weakref
+import pygeoapi.l10n
+import pygeoapi.plugin
+import pygeoapi.api
+from typing import Tuple, Any, Dict, Generator
+from http import HTTPStatus
+from copy import deepcopy
+from pygeoapi.openapi import get_oas
+from ogc import podpac as pogc
+
+from .edr_api import EdrAPI
+from .edr_config import EdrConfig
+from .edr_provider import EdrProvider
+
+
+class EdrRoutes(tl.HasTraits):
+ """Class responsible for routing EDR requests to the appropriate pygeoapi API method."""
+
+ base_url = tl.Unicode(default_value="http://127.0.0.1:5000/edr")
+ layers = tl.List(trait=tl.Instance(pogc.Layer))
+
+ def __init__(self, **kwargs):
+ """Initialize the API based on the available layers."""
+ super().__init__(**kwargs)
+ self.api = self.create_api()
+
+ @tl.observe("layers")
+ def layers_change(self, change: Dict[str, Any]):
+ """Monitor the layers and update the API when a change occurs.
+
+ Parameters
+ ----------
+ change : Dict[str, Any]
+ Dictionary holding type of modification and name of the attribute that triggered it.
+ """
+ self.api = self.create_api()
+
+ @tl.observe("base_url")
+ def base_url_change(self, change: Dict[str, Any]):
+ """Monitor the base url and update the API when a change occurs.
+
+ Parameters
+ ----------
+ change : Dict[str, Any]
+ Dictionary holding type of modification and name of the attribute that triggered it.
+ """
+ self.api = self.create_api()
+
+ def create_api(self) -> pygeoapi.api.API:
+ """Create the pygeoapi API using a custom configuration.
+
+ Returns
+ -------
+ pygeoapi.api.API
+ The API which handles all EDR requests.
+ """
+ # Allow specifying GeoTiff or CoverageJSON in the format argument.
+ # This is a bypass which is needed to get by a conditional check in pygeoapi.
+ pygeoapi.plugin.PLUGINS["formatter"]["geotiff"] = ""
+ pygeoapi.plugin.PLUGINS["formatter"]["coveragejson"] = ""
+ EdrProvider.set_layers(self.base_url, self.layers)
+ config = EdrConfig.get_configuration(self.base_url, self.layers)
+ open_api = get_oas(config, fail_on_invalid_collection=False)
+ return pygeoapi.api.API(config=deepcopy(config), openapi=open_api)
+
+ def clean_configuration_cache(self):
+ """Clean a pygeoapi internal translation cache so that multiple configurations can be used simultaneously."""
+ pygeoapi.l10n._cfg_cache = {}
+
+ def update_configuration_base_url(self, request: pygeoapi.api.APIRequest):
+ """Update the EDR configuration base URL based on the provided request.
+ The EDR configuration base URL does not necessarily match the full path of the request base URL.
+
+ Parameters
+ ----------
+ request : pygeoapi.api.APIRequest
+ The API request containing a base URL.
+ The base URL should include scheme, host, and path for the request.
+ """
+ request_base_url = request.params.get("base_url", "")
+ # Limit the base URL from the request to the EDR subdirectory
+ base_url_partitioned = request_base_url.partition("/edr")
+ if len(base_url_partitioned[0]) > 0:
+ configuration_base_url = base_url_partitioned[0] + base_url_partitioned[1]
+ if configuration_base_url != self.base_url:
+ self.base_url = configuration_base_url
+
+ def static_files(self, request: pygeoapi.api.APIRequest, file_path: str) -> Tuple[dict, int, str | bytes]:
+ """Handle static file requests using the custom static file folder or the pygeoapi default folder.
+
+ Parameters
+ ----------
+ file_path : str
+ The file path of the requested static resource.
+
+ Returns
+ -------
+ Tuple[dict, int, str | bytes]
+ Headers, HTTP Status, and Content returned as a tuple to make the server response.
+ """
+ self.clean_configuration_cache()
+ self.update_configuration_base_url(request)
+ static_path = os.path.join(os.path.dirname(pygeoapi.__file__), "static")
+ if "templates" in self.api.config["server"]:
+ static_path = self.api.config["server"]["templates"].get("static", static_path)
+ file_path = os.path.join(static_path, file_path)
+ if os.path.isfile(file_path):
+ mime_type, _ = mimetypes.guess_type(file_path)
+ mime_type = mime_type or "application/octet-stream"
+ with open(file_path, "rb") as f:
+ content = f.read()
+ return {"Content-Type": mime_type}, HTTPStatus.OK, content
+ else:
+ return {}, HTTPStatus.NOT_FOUND, b"File not found"
+
+ def landing_page(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | bytes]:
+ """Handle landing page requests for the server.
+
+ Parameters
+ ----------
+ request : pygeoapi.api.APIRequest
+ The pygeoapi request for the server.
+
+ Returns
+ -------
+ Tuple[dict, int, str | bytes]
+ Headers, HTTP Status, and Content returned as a tuple to make the server response.
+ """
+ self.clean_configuration_cache()
+ self.update_configuration_base_url(request)
+ return EdrAPI.landing_page(self.api, request)
+
+ def openapi(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | bytes]:
+ """Handle API documentation requests for the server.
+
+ Parameters
+ ----------
+ request : pygeoapi.api.APIRequest
+ The pygeoapi request for the server.
+
+ Returns
+ -------
+ Tuple[dict, int, str | bytes]
+ Headers, HTTP Status, and Content returned as a tuple to make the server response.
+ """
+ self.clean_configuration_cache()
+ self.update_configuration_base_url(request)
+ return EdrAPI.openapi_(self.api, request)
+
+ def conformance(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | bytes]:
+ """Handle conformance requests for the server.
+
+ Parameters
+ ----------
+ request : pygeoapi.api.APIRequest
+ The pygeoapi request for the server.
+
+ Returns
+ -------
+ Tuple[dict, int, str | bytes]
+ Headers, HTTP Status, and Content returned as a tuple to make the server response.
+ """
+ self.clean_configuration_cache()
+ self.update_configuration_base_url(request)
+ return EdrAPI.conformance(self.api, request)
+
+ def describe_collections(
+ self,
+ request: pygeoapi.api.APIRequest,
+ collection_id: str | None,
+ ) -> Tuple[dict, int, str | bytes]:
+ """Handle describe collection requests for the server.
+
+ Parameters
+ ----------
+ request : pygeoapi.api.APIRequest
+ The pygeoapi request for the server.
+ collection_id : str | None
+ The collection ID to describe.
+
+ Returns
+ -------
+ Tuple[dict, int, str | bytes]
+ Headers, HTTP Status, and Content returned as a tuple to make the server response.
+ """
+ self.clean_configuration_cache()
+ self.update_configuration_base_url(request)
+ return EdrAPI.describe_collections(self.api, request, collection_id)
+
+ def describe_instances(
+ self,
+ request: pygeoapi.api.APIRequest,
+ collection_id: str,
+ instance_id: str | None,
+ ) -> Tuple[dict, int, str | bytes]:
+ """Handle collection instances requests for the server.
+
+ Parameters
+ ----------
+ request : pygeoapi.api.APIRequest
+ The pygeoapi request for the server.
+ collection_id : str
+ The collection ID for the instances.
+ instance_id: str
+ The instance ID to describe.
+
+ Returns
+ -------
+ Tuple[dict, int, str | bytes]
+ Headers, HTTP Status, and Content returned as a tuple to make the server response.
+ """
+ self.clean_configuration_cache()
+ self.update_configuration_base_url(request)
+ return EdrAPI.get_collection_edr_instances(self.api, request, collection_id, instance_id=instance_id)
+
+ def collection_query(
+ self,
+ request: pygeoapi.api.APIRequest,
+ collection_id: str,
+ instance_id: str | None,
+ query_type: str,
+ ) -> Tuple[dict, int, Any]:
+ """Handle collection and instance query requests for the server.
+
+ Parameters
+ ----------
+ request : pygeoapi.api.APIRequest
+ The pygeoapi request for the server.
+ query_type: str
+ The query type for the request.
+ collection_id : str
+ The collection ID for the query.
+ instance_id: str
+ The instance ID for the query.
+
+ Returns
+ -------
+ Tuple[dict, int, Any]
+ Headers, HTTP Status, and Content returned as a tuple to make the server response.
+ """
+ self.clean_configuration_cache()
+ self.update_configuration_base_url(request)
+ EdrProvider.set_extra_query_args(request._args)
+ headers, http_status, content = EdrAPI.get_collection_edr_query(
+ self.api, request, collection_id, instance_id, query_type=query_type, location_id=None
+ )
+
+ if "text/html" in headers.get("Content-Type", ""):
+ return headers, http_status, content
+
+ content = json.loads(content)
+ if "fn" in content and "fp" in content:
+ 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/static/css/default.css b/ogc/edr/static/css/default.css
new file mode 100644
index 0000000..36f9261
--- /dev/null
+++ b/ogc/edr/static/css/default.css
@@ -0,0 +1,86 @@
+.flat {
+ border: 0px;
+}
+
+header {
+ display: inline-block;
+}
+
+main {
+ background-color: white;
+}
+
+.crumbs {
+ background-color: rgb(230, 230, 230);
+ padding: 6px;
+}
+
+.crumbs a {
+ padding: 0px 6px;
+ color: black;
+ text-decoration: none;
+ /* text-transform: capitalize;*/
+}
+
+#items-map,
+#collection-map {
+ width: 100%;
+ height: 400px;
+}
+
+#coverages-map {
+ width: 100%;
+ height: 80vh;
+}
+
+.c3-tooltip-container {
+ z-index: 300;
+}
+
+/* cancel mini-css header>button uppercase */
+header button,
+header [type="button"],
+header .button,
+header [role="button"] {
+ text-transform: none;
+}
+
+html {
+ background-color: #fff;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ background-color: #fff;
+}
+
+footer.sticky-bottom {
+ margin-top: auto;
+}
+
+main {
+ padding-bottom: 65px;
+ /* prevent from falling under the footer */
+}
+
+table:not(.horizontal) {
+ max-height: none;
+}
+
+mark.successful {
+ background-color: green;
+}
+
+mark.accepted {
+ background-color: default;
+}
+
+mark.failed {
+ background-color: red;
+}
+
+mark.running {
+ background-color: orange;
+}
\ No newline at end of file
diff --git a/ogc/edr/static/img/favicon.ico b/ogc/edr/static/img/favicon.ico
new file mode 100644
index 0000000..1e20f9c
Binary files /dev/null and b/ogc/edr/static/img/favicon.ico differ
diff --git a/ogc/edr/static/img/logo.png b/ogc/edr/static/img/logo.png
new file mode 100644
index 0000000..4b8ebad
Binary files /dev/null and b/ogc/edr/static/img/logo.png differ
diff --git a/ogc/edr/static/img/pygeoapi.png b/ogc/edr/static/img/pygeoapi.png
new file mode 100644
index 0000000..0477111
Binary files /dev/null and b/ogc/edr/static/img/pygeoapi.png differ
diff --git a/ogc/edr/test/conftest.py b/ogc/edr/test/conftest.py
new file mode 100644
index 0000000..d566273
--- /dev/null
+++ b/ogc/edr/test/conftest.py
@@ -0,0 +1,134 @@
+import os
+import pytest
+import numpy as np
+import datetime
+import podpac
+from ogc import podpac as pogc
+from typing import Dict, List, Any
+from ogc.settings import EDR_TIME_INSTANCE_DIMENSION
+
+# Setup new dimension
+podpac.core.coordinates.utils.add_valid_dimension(EDR_TIME_INSTANCE_DIMENSION)
+
+lat = np.linspace(90, -90, 11)
+lon = np.linspace(-180, 180, 21)
+time = np.array(["2025-10-24T12:00:00"], dtype="datetime64")
+instance = np.array(["2025-10-24T00:00:00"], dtype="datetime64")
+data = np.random.default_rng(1).random((11, 21, 1, 1))
+coords = podpac.Coordinates([lat, lon, time, instance], dims=["lat", "lon", "time", EDR_TIME_INSTANCE_DIMENSION])
+data_without_instance = np.random.default_rng(1).random((11, 21, 1))
+coords_without_instance = podpac.Coordinates([lat, lon, time], dims=["lat", "lon", "time"])
+
+# Define test layers using sample data and coordinates
+node1 = podpac.data.Array(source=data, coordinates=coords)
+layer1 = pogc.Layer(
+ node=node1,
+ identifier="layer1",
+ title="Layer 1",
+ abstract="Layer1 Data",
+ group="Layers",
+ valid_times=[dt.astype(datetime.datetime) for dt in time],
+)
+node2 = podpac.data.Array(source=data, coordinates=coords)
+layer2 = pogc.Layer(
+ node=node2,
+ identifier="layer2",
+ title="Layer 2",
+ abstract="Layer2 Data",
+ group="Layers",
+ valid_times=[dt.astype(datetime.datetime) for dt in time],
+)
+node3 = podpac.data.Array(source=data_without_instance, coordinates=coords_without_instance)
+layer3 = pogc.Layer(
+ node=node3,
+ identifier="layer3",
+ title="Layer 3",
+ abstract="Layer3 Data (No instance)",
+ group="Layers",
+ valid_times=[dt.astype(datetime.datetime) for dt in time],
+)
+
+
+@pytest.fixture(scope="session", autouse=True)
+def set_env_vars():
+ """Setup the environmental variables for the session to support EDR."""
+ os.environ["OGC_SUPPORTED_FORMATS"] = "edr"
+
+
+@pytest.fixture()
+def layers() -> List[pogc.Layer]:
+ """List of test layers.
+
+ Returns
+ -------
+ List[pogc.Layer]
+ The test layers.
+ """
+ return [layer1, layer2]
+
+
+@pytest.fixture()
+def layers_no_instance() -> List[pogc.Layer]:
+ """List of test layers without instances.
+
+ Returns
+ -------
+ List[pogc.Layer]
+ The test layers.
+ """
+ return [layer3]
+
+
+@pytest.fixture()
+def single_layer_cube_args() -> Dict[str, Any]:
+ """Dictionary of valid request arguments that align to a single test layer cube request.
+
+ Returns
+ -------
+ Dict[str, Any]
+ Valid cube request arguments for a single test layer.
+ """
+
+ return {
+ "f": "coveragejson",
+ "bbox": "-180, -90, 180, 90",
+ "datetime": str(time[0]),
+ "parameter-name": [layer1.identifier],
+ }
+
+
+@pytest.fixture()
+def single_layer_cube_args_internal() -> Dict[str, Any]:
+ """Dictionary of valid arguments that align to a single test layer request with internal pygeoapi keys.
+
+ Returns
+ -------
+ Dict[str, Any]
+ Valid internal cube arguments for a single test layer.
+ """
+
+ return {
+ "format_": "coveragejson",
+ "instance": str(instance[0]),
+ "bbox": [-180, -90, 180, 90],
+ "datetime_": str(time[0]),
+ "select_properties": [layer1.identifier],
+ }
+
+
+@pytest.fixture()
+def single_layer_cube_args_no_instance_internal() -> Dict[str, Any]:
+ """Dictionary of valid arguments that align to a single non-instance test layer request with internal pygeoapi keys.
+
+ Returns
+ -------
+ Dict[str, Any]
+ Valid internal cube arguments for a single non-instance test layer.
+ """
+
+ return {
+ "format_": "coveragejson",
+ "bbox": [-180, -90, 180, 90],
+ "datetime_": str(time[0]),
+ "select_properties": [layer3.identifier],
+ }
diff --git a/ogc/edr/test/test_edr_config.py b/ogc/edr/test/test_edr_config.py
new file mode 100644
index 0000000..e651c49
--- /dev/null
+++ b/ogc/edr/test/test_edr_config.py
@@ -0,0 +1,64 @@
+from typing import Dict, List, Any
+from ogc import podpac as pogc
+from ogc.edr.edr_config import EdrConfig
+
+
+def test_edr_default_configuration_has_required_keys():
+ """Test the EDR default configuration loads the required keys."""
+ configuration = EdrConfig.get_configuration("/ogc", [])
+
+ assert configuration.keys() == {"server", "logging", "metadata", "resources"}
+
+
+def test_edr_configuration_contains_layer_groups(layers: List[pogc.Layer]):
+ """Test the EDR configuration contains the layer groups.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ group_keys = {layer.group for layer in layers}
+ configuration = EdrConfig.get_configuration("/ogc", layers)
+
+ assert len(group_keys) > 0
+ for key in group_keys:
+ assert configuration["resources"].get(key) is not None
+
+
+def test_edr_configuration_contains_spatial_extent(layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any]):
+ """Test the EDR configuration contains the spatial extent.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+
+ single_layer_cube_args : Dict[str, Any]
+ Single layer arguments for validation checking provided by a test fixture.
+ """
+ group_keys = {layer.group for layer in layers}
+ configuration = EdrConfig.get_configuration("/ogc", layers)
+
+ assert len(group_keys) > 0
+ for key in group_keys:
+ assert configuration["resources"][key]["extents"]["spatial"]["bbox"] == list(
+ map(float, single_layer_cube_args["bbox"].split(","))
+ )
+
+
+def test_edr_configuration_contains_custom_provider(layers: List[pogc.Layer]):
+ """Test the EDR configuration contains the custom provider.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ group_keys = {layer.group for layer in layers}
+ configuration = EdrConfig.get_configuration("/ogc", layers)
+
+ assert len(group_keys) > 0
+ for key in group_keys:
+ assert configuration["resources"][key]["providers"][0]["type"] == "edr"
+ assert configuration["resources"][key]["providers"][0]["name"] == "ogc.edr.edr_provider.EdrProvider"
diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py
new file mode 100644
index 0000000..c081781
--- /dev/null
+++ b/ogc/edr/test/test_edr_provider.py
@@ -0,0 +1,781 @@
+import pytest
+import numpy as np
+import zipfile
+import io
+import json
+import os
+import podpac
+import pyproj
+from shapely import Point, Polygon
+from typing import Dict, List, Any
+from ogc import settings
+from ogc import podpac as pogc
+from ogc.edr.edr_provider import EdrProvider
+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.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers for the provider to use in defining available data sources.
+
+ Returns
+ -------
+ Dict[str, Any]
+ The provider definition which defines data sources.
+ """
+ return {
+ "type": "edr",
+ "default": True,
+ "name": "ogc.edr.edr_provider.EdrProvider",
+ "data": "Layers",
+ "base_url": base_url,
+ "crs": list(settings.EDR_CRS.keys()),
+ "format": {"name": "GeoJSON", "mimetype": "application/json"},
+ }
+
+
+def test_edr_provider_resources(layers: List[pogc.Layer]):
+ """Test the available resources of the EDR Provider class.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ base_url = "/"
+ identifiers = [layer.identifier for layer in layers]
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ provider.set_layers(base_url, layers)
+
+ assert len(provider.get_layers(base_url)) == len(layers)
+ assert all(layer.identifier in identifiers for layer in provider.get_layers(base_url))
+
+
+def test_edr_provider_resources_limited_by_url(layers: List[pogc.Layer]):
+ """Test the available resources of the EDR Provider class are limited by URL.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ base_url = "/"
+ invalid_url = "/invalid"
+
+ provider = EdrProvider(provider_def=get_provider_definition(invalid_url))
+ provider.set_layers(base_url, layers)
+
+ assert len(provider.get_layers(invalid_url)) == 0
+ assert len(provider.get_layers(base_url)) == len(layers)
+
+
+def test_edr_provider_get_instance_valid_id(layers: List[pogc.Layer]):
+ """Test the get_instance method of the EDR Provider class with a valid id.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ base_url = "/"
+ time_instance = next(iter(layers[0].time_instances()))
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ provider.set_layers(base_url, layers)
+
+ assert provider.get_instance(time_instance) == time_instance
+
+
+def test_edr_provider_get_instance_invalid_id(layers: List[pogc.Layer]):
+ """Test the get_instance method of the EDR Provider class with an invalid id.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ base_url = "/"
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ provider.set_layers(base_url, layers)
+
+ assert provider.get_instance("invalid") is None
+
+
+def test_edr_provider_parameter_keys(layers: List[pogc.Layer]):
+ """Test the parameters property of the EDR Provider class.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ base_url = "/"
+ identifiers = [layer.identifier for layer in layers]
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ provider.set_layers(base_url, layers)
+ parameters = provider.parameters
+
+ assert len(list(parameters.keys())) == len(layers)
+ assert all(identifier in identifiers for identifier in parameters.keys())
+
+
+def test_edr_provider_instances(layers: List[pogc.Layer]):
+ """Test the instances method of the EDR Provider class.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ base_url = "/"
+ instance_sets = [layer.time_instances() for layer in layers]
+ time_instances = set().union(*instance_sets)
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ provider.set_layers(base_url, layers)
+ instances = provider.instances()
+
+ assert len(instances) == len(time_instances)
+ assert instances == [str(t) for t in time_instances]
+
+
+def test_edr_provider_get_fields(layers: List[pogc.Layer]):
+ """Test the get fields method of the EDR Provider class.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ base_url = "/"
+ identifiers = [layer.identifier for layer in layers]
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ provider.set_layers(base_url, layers)
+ fields = provider.get_fields()
+
+ assert len(fields.keys()) == len(layers)
+ assert all(identifier in identifiers for identifier in fields.keys())
+
+
+def test_edr_provider_position_request_valid_wkt(
+ layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]
+):
+ """Test the position method of the EDR Provider class with a valid WKT.
+
+ 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)
+ parameter_name = single_layer_cube_args_internal["select_properties"][0]
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ 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"]) == {"x", "y", "t"}
+ assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len(
+ response["domain"]["ranges"][parameter_name]["values"]
+ )
+
+
+def test_edr_provider_position_request_invalid_wkt(
+ layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]
+):
+ """Test the position method of the EDR Provider class with an invalid WKT.
+
+ 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"] = "invalid"
+
+ 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_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]
+):
+ """Test the position method of the EDR Provider class with an invalid property.
+
+ 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["select_properties"] = "invalid"
+
+ 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_cube_request_valid_bbox(
+ layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]
+):
+ """Test the cube method of the EDR Provider class with a valid bounding box.
+
+ 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
+ parameter_name = single_layer_cube_args_internal["select_properties"][0]
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ 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"]) == {"x", "y", "t"}
+ assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len(
+ response["domain"]["ranges"][parameter_name]["values"]
+ )
+
+
+def test_edr_provider_cube_request_valid_bbox_with_resolution(
+ layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]
+):
+ """Test the cube method of the EDR Provider class with a valid bounding box and a specific resolution.
+
+ The tested node is adjusted to ensure interpolation is used.
+
+ 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
+ parameter_name = single_layer_cube_args_internal["select_properties"][0]
+ resolution_x = 15
+ resolution_y = 20
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ layers[0].node = layers[0].node.interpolate()
+ provider.set_layers(base_url, layers)
+ 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"]) == {"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"]
+ )
+
+
+def test_edr_provider_cube_request_invalid_bbox(
+ layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]
+):
+ """Test the cube method of the EDR Provider class with an invalid bounding box.
+
+ 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
+ args["bbox"] = "invalid"
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ provider.set_layers(base_url, layers)
+
+ with pytest.raises(ProviderInvalidQueryError):
+ provider.cube(**args)
+
+
+def test_edr_provider_cube_request_without_instance(
+ layers_no_instance: List[pogc.Layer], single_layer_cube_args_no_instance_internal: Dict[str, Any]
+):
+ """Test the cube method of the EDR Provider class with a valid layer without an instance.
+
+ Parameters
+ ----------
+ layers_no_instance : List[pogc.Layer]
+ Layers provided by a test fixture.
+
+ single_layer_cube_args_no_instance_internal : Dict[str, Any]
+ Single layer arguments with internal pygeoapi keys provided by a test fixture.
+ """
+ base_url = "/"
+ args = single_layer_cube_args_no_instance_internal
+ parameter_name = single_layer_cube_args_no_instance_internal["select_properties"][0]
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ provider.set_layers(base_url, layers_no_instance)
+
+ response = provider.cube(**args)
+ response = get_json_with_cleanup(response["fp"])
+
+ 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"]
+ )
+
+
+def test_edr_provider_cube_request_invalid_instance(
+ layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]
+):
+ """Test the cube method of the EDR Provider class with an invalid instance.
+
+ 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
+ args["instance"] = "invalid"
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ provider.set_layers(base_url, layers)
+
+ with pytest.raises(ProviderInvalidQueryError):
+ provider.cube(**args)
+
+
+def test_edr_provider_cube_request_invalid_altitude(
+ layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]
+):
+ """Test the cube method of the EDR Provider class with an invalid altitude.
+
+ 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
+ args["z"] = "invalid"
+
+ 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_area_request_valid_wkt(layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]):
+ """Test the area method of the EDR Provider class with a valid wkt.
+
+ 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"] = Polygon(((-180.0, -90.0), (-180.0, 90.0), (180.0, -90.0), (180.0, 90.0)))
+ parameter_name = single_layer_cube_args_internal["select_properties"][0]
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ 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"]) == {"x", "y", "t"}
+ assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len(
+ response["domain"]["ranges"][parameter_name]["values"]
+ )
+
+
+def test_edr_provider_area_request_invalid_wkt(
+ layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]
+):
+ """Test the area method of the EDR Provider class with an invalid wkt.
+
+ 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"] = "invalid"
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ provider.set_layers(base_url, layers)
+
+ with pytest.raises(ProviderInvalidQueryError):
+ provider.area(**args)
+
+
+def test_edr_provider_cube_request_invalid_datetime(
+ layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]
+):
+ """Test the area method of the EDR Provider class with an invalid datetime.
+
+ 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
+ args["datetime_"] = "10_24/2025"
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ provider.set_layers(base_url, layers)
+
+ with pytest.raises(ProviderInvalidQueryError):
+ provider.cube(**args)
+
+
+def test_edr_provider_cube_request_valid_geotiff_format(
+ layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]
+):
+ """Test the query method of the EDR Provider class with a valid geotiff request.
+
+ 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
+ args["format_"] = "geotiff"
+ parameter_name = single_layer_cube_args_internal["select_properties"][0]
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ 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(data) > 0
+
+
+def test_edr_provider_cube_request_valid_geotiff_format_multiple_parameters(
+ layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]
+):
+ """Test the query method of the EDR Provider class with a valid geotiff request.
+
+ 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
+ args["format_"] = "geotiff"
+
+ # Set the properties argument as multiple layers from the same group/collection
+ group = layers[0].group
+ selected_layers = [layer.identifier for layer in layers if layer.group == group]
+ args["select_properties"] = selected_layers
+
+ provider = EdrProvider(provider_def=get_provider_definition(base_url))
+ provider.set_layers(base_url, layers)
+
+ response = provider.cube(**args)
+ 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:
+ namelist = zf.namelist()
+ assert len(namelist) > 0
+ assert all(f"{layer}.tif" in namelist for layer in selected_layers)
+
+
+def test_edr_provider_datetime_single_value():
+ """Test the datetime interpreter method of the EDR Provider class with a single datetime value."""
+ time_string = "2025-10-24"
+ available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"]
+ available_times = [np.datetime64(time) for time in available_times]
+ expected_times = available_times[0]
+
+ time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None, None)
+
+ assert time_coords is not None
+ np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times)
+
+
+def test_edr_provider_datetime_range_closed():
+ """Test the datetime interpreter method of the EDR Provider class with a closed datetime range."""
+ time_string = "2025-10-24/2025-10-26"
+ available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"]
+ available_times = [np.datetime64(time) for time in available_times]
+ expected_times = available_times[0:3]
+
+ time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None, None)
+
+ assert time_coords is not None
+ np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times)
+
+
+def test_edr_provider_datetime_open_start():
+ """Test the datetime interpreter method of the EDR Provider class with a open datetime start."""
+ time_string = "../2025-10-27"
+ available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"]
+ available_times = [np.datetime64(time) for time in available_times]
+ expected_times = available_times[0:4]
+
+ time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None, None)
+
+ assert time_coords is not None
+ np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times)
+
+
+def test_edr_provider_datetime_open_end():
+ """Test the datetime interpreter method of the EDR Provider class with a open datetime end."""
+ time_string = "2025-10-25/.."
+ available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"]
+ available_times = [np.datetime64(time) for time in available_times]
+ expected_times = available_times[1:]
+
+ time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None, None)
+
+ assert time_coords is not None
+ np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times)
+
+
+def test_edr_provider_datetime_invalid_string():
+ """Test the datetime interpreter method of the EDR Provider class with an invalid string."""
+ time_string = "2025-10-25/../../.."
+ available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"]
+ available_times = [np.datetime64(time) for time in available_times]
+
+ with pytest.raises(ProviderInvalidQueryError):
+ EdrProvider.interpret_time_coordinates(available_times, time_string, None, None)
+
+
+def test_edr_provider_get_altitudes():
+ """Test the get altitudes method of the EDR Provider class with a layer containing altitude data."""
+ latitude = np.arange(1, 5)
+ longitude = np.arange(1, 5)
+ altitude = np.arange(1, 10)
+ data = np.random.default_rng(1).random((len(latitude), len(longitude), len(altitude)))
+ coords = podpac.Coordinates([latitude, longitude, altitude], dims=["lat", "lon", "alt"])
+ node = podpac.data.Array(source=data, coordinates=coords)
+ layer = pogc.Layer(node=node, identifier="Test")
+
+ np.testing.assert_array_equal(EdrProvider.get_altitudes([layer]), altitude)
+
+
+def test_edr_provider_altitude_single_value():
+ """Test the altitude interpreter method of the EDR Provider class with a single datetime value."""
+ altitude_string = "10"
+ available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0]
+ expected_altitudes = [10.0]
+
+ altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None)
+
+ assert altitude_coords is not None
+ np.testing.assert_array_equal(altitude_coords["alt"].coordinates, expected_altitudes)
+
+
+def test_edr_provider_altitude_range_closed():
+ """Test the altitude interpreter method of the EDR Provider class with a closed datetime range."""
+ altitude_string = "10/20"
+ available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0]
+ expected_altitudes = [10.0, 15.0, 20.0]
+
+ altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None)
+
+ assert altitude_coords is not None
+ np.testing.assert_array_equal(altitude_coords["alt"].coordinates, expected_altitudes)
+
+
+def test_edr_provider_altitude_repeating_interval():
+ """Test the altitude interpreter method of the EDR Provider class with a repeating interval."""
+ altitude_string = "R2/5/5"
+ available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0]
+ expected_altitudes = [5.0, 10.0]
+
+ altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None)
+
+ assert altitude_coords is not None
+ np.testing.assert_array_equal(altitude_coords["alt"].coordinates, expected_altitudes)
+
+
+def test_edr_provider_altitude_list():
+ """Test the altitude interpreter method of the EDR Provider class with a list."""
+ altitude_string = "5,10,15"
+ available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0]
+ expected_altitudes = [5.0, 10.0, 15.0]
+
+ altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None)
+
+ assert altitude_coords is not None
+ np.testing.assert_array_equal(altitude_coords["alt"].coordinates, expected_altitudes)
+
+
+def test_edr_provider_altitude_invalid_string():
+ """Test the altitude interpreter method of the EDR Provider class with an invalid string."""
+ altitude_string = "../20"
+ available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0]
+
+ with pytest.raises(ProviderInvalidQueryError):
+ EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None)
+
+
+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) == 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) == pyproj.CRS(settings.epsg_4326_uri_format).to_wkt()
+ )
+
+
+def test_edr_provider_crs_interpreter_invalid_value():
+ """Test the CRS interpretation raises an exception when an invalid argument is provided."""
+ with pytest.raises(ProviderInvalidQueryError):
+ EdrProvider.interpret_crs("epsp:4444")
+
+
+def test_edr_provider_crs_converter():
+ """Test the CRS converter returns latitude and longitude data properly."""
+ x = [1, 2, 3]
+ y = [3, 4, 5]
+
+ # EPSG:4326 specifies x (latitude) and y (longitude)
+ lon = y
+ lat = x
+
+ assert EdrProvider.crs_converter(x, y, crs=settings.epsg_4326_uri_format) == (lon, lat)
diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py
new file mode 100644
index 0000000..4931f99
--- /dev/null
+++ b/ogc/edr/test/test_edr_routes.py
@@ -0,0 +1,426 @@
+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
+from werkzeug.test import create_environ
+from werkzeug.wrappers import Request
+from werkzeug.datastructures import ImmutableMultiDict
+from ogc import podpac as pogc
+from ogc.edr.edr_routes import EdrRoutes
+
+
+def mock_request(request_args: Dict[str, Any] | None = None) -> APIRequest:
+ """Creates a mock request for EDR routes to use.
+
+
+ Parameters
+ ----------
+ request_args: Dict[str, Any] | None, optional
+ The dictionary for query string arguments, by default None.
+
+ Returns
+ -------
+ APIRequest
+ Mock API request for route testing.
+ """
+ request_args = request_args if request_args is not None else {}
+ environ = create_environ(base_url="http://127.0.0.1:5000/ogc/edr")
+ request = Request(environ)
+ request.args = ImmutableMultiDict(request_args.items())
+ return APIRequest(request, ["en"])
+
+
+def test_edr_routes_static_files_valid_path():
+ """Test the EDR static routes with a valid static file path."""
+ request = mock_request()
+ edr_routes = EdrRoutes(layers=[])
+
+ headers, status, _ = edr_routes.static_files(request, "img/logo.png")
+
+ assert status == HTTPStatus.OK
+ assert headers["Content-Type"] == "image/png"
+
+
+def test_edr_routes_static_files_invalid_path():
+ """Test the EDR static routes with an invalid static file path."""
+ request = mock_request()
+ edr_routes = EdrRoutes(layers=[])
+
+ _, status, _ = edr_routes.static_files(request, "invalid")
+
+ assert status == HTTPStatus.NOT_FOUND
+
+
+def test_edr_routes_landing_page():
+ """Test the EDR landing page for a response."""
+ request = mock_request({"f": "json"})
+ edr_routes = EdrRoutes(layers=[])
+
+ headers, status, _ = edr_routes.landing_page(request)
+
+ assert status == HTTPStatus.OK
+ assert headers["Content-Type"] == "application/json"
+
+
+def test_edr_routes_landing_page_html():
+ """Test the EDR landing page for a response."""
+ request = mock_request({"f": "html"})
+ edr_routes = EdrRoutes(layers=[])
+
+ headers, status, _ = edr_routes.landing_page(request)
+
+ assert status == HTTPStatus.OK
+ assert headers["Content-Type"] == "text/html"
+
+
+def test_edr_routes_conformance(layers: List[pogc.Layer]):
+ """Test the EDR conformance for a response.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ request = mock_request({"f": "json"})
+ edr_routes = EdrRoutes(layers=layers)
+
+ _, status, content = edr_routes.conformance(request)
+ response = json.loads(content)
+
+ assert status == HTTPStatus.OK
+ assert len(response["conformsTo"]) > 0
+ assert "https://www.opengis.net/spec/ogcapi-edr-1/1.1/conf/core" in response["conformsTo"]
+
+
+def test_edr_routes_api():
+ """Test the EDR api documentation for a response."""
+ request = mock_request({"f": "json"})
+ edr_routes = EdrRoutes(layers=[])
+
+ _, status, content = edr_routes.openapi(request)
+ response = json.loads(content)
+
+ assert status == HTTPStatus.OK
+ assert response["paths"]["/"]
+ assert response["paths"]["/api"]
+ assert response["paths"]["/conformance"]
+ assert response["paths"]["/collections"]
+
+
+def test_edr_routes_describe_collections(layers: List[pogc.Layer]):
+ """Test the EDR collections description for a response.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ request = mock_request({"f": "json"})
+ edr_routes = EdrRoutes(layers=layers)
+ collections = {layer.group for layer in layers}
+
+ _, status, content = edr_routes.describe_collections(request, collection_id=None)
+ response = json.loads(content)
+
+ assert status == HTTPStatus.OK
+ assert len(response["collections"]) == len(collections)
+
+ response_collection_ids = [collection["id"] for collection in response["collections"]]
+
+ assert response_collection_ids == list(collections)
+
+
+def test_edr_routes_describe_collection(layers: List[pogc.Layer]):
+ """Test the EDR collection description for a response.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ request = mock_request({"f": "json"})
+ edr_routes = EdrRoutes(layers=layers)
+ collection_id = layers[0].group
+ collection_layers = [layer for layer in layers if layer.group == collection_id]
+
+ _, status, content = edr_routes.describe_collections(request, collection_id=collection_id)
+ response = json.loads(content)
+
+ 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()) == ["instances"]
+
+
+def test_edr_routes_describe_collection_without_instance(layers_no_instance: List[pogc.Layer]):
+ """Test the EDR collection description for a response without instances.
+ The instance data query should still be available so it can return empty.
+
+ Parameters
+ ----------
+ layers_no_instance : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ request = mock_request({"f": "json"})
+ edr_routes = EdrRoutes(layers=layers_no_instance)
+ collection_id = layers_no_instance[0].group
+ collection_layers = [layer for layer in layers_no_instance if layer.group == collection_id]
+
+ _, status, content = edr_routes.describe_collections(request, collection_id=collection_id)
+ response = json.loads(content)
+
+ assert status == HTTPStatus.OK
+ assert response["id"] == collection_id
+ assert list(response["parameter_names"].keys()) == [layer.identifier for layer in collection_layers]
+ assert set(response["data_queries"].keys()) == {"position", "cube", "area", "instances"}
+
+
+def test_edr_routes_describe_instances(layers: List[pogc.Layer]):
+ """Test the EDR instances description for a response.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ request = mock_request({"f": "json"})
+ edr_routes = EdrRoutes(layers=layers)
+ collection_id = layers[0].group
+ time_instances = set()
+ for layer in layers:
+ if layer.group == collection_id:
+ time_instances.update(layer.time_instances())
+
+ _, status, content = edr_routes.describe_instances(request, collection_id=collection_id, instance_id=None)
+ response = json.loads(content)
+
+ assert status == HTTPStatus.OK
+ assert len(response["instances"]) == len(time_instances)
+
+ response_time_instances_ids = [instance["id"] for instance in response["instances"]]
+ assert response_time_instances_ids == list(time_instances)
+
+
+def test_edr_routes_describe_instances_without_instances(layers_no_instance: List[pogc.Layer]):
+ """Test the EDR instances description for a response when no instances are available.
+
+ Parameters
+ ----------
+ layers_no_instance : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ request = mock_request({"f": "json"})
+ edr_routes = EdrRoutes(layers=layers_no_instance)
+ collection_id = layers_no_instance[0].group
+
+ _, status, content = edr_routes.describe_instances(request, collection_id=collection_id, instance_id=None)
+ response = json.loads(content)
+
+ assert status == HTTPStatus.OK
+ assert len(response["instances"]) == 0
+
+
+def test_edr_routes_describe_instance(layers: List[pogc.Layer]):
+ """Test the EDR instance description for a response.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+ """
+ request = mock_request({"f": "json"})
+ edr_routes = EdrRoutes(layers=layers)
+ collection_id = layers[0].group
+ instance_id = next(iter(layers[0].time_instances()))
+
+ _, status, content = edr_routes.describe_instances(request, collection_id=collection_id, instance_id=instance_id)
+ response = json.loads(content)
+
+ assert status == HTTPStatus.OK
+ assert response["id"] == instance_id
+ assert list(response["data_queries"].keys()) == ["position", "cube", "area"]
+
+
+def test_edr_routes_collection_query(layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any]):
+ """Test the EDR collection query for a reponse.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+
+ single_layer_cube_args : Dict[str, Any]
+ Single layer arguments provided by a test fixture.
+ """
+ collection_id = layers[0].group
+ instance_id = next(iter(layers[0].time_instances()))
+ parameter_name = single_layer_cube_args["parameter-name"][0]
+ request = mock_request(single_layer_cube_args)
+ edr_routes = EdrRoutes(layers=layers)
+
+ _, status, content = edr_routes.collection_query(
+ request,
+ collection_id=collection_id,
+ instance_id=instance_id,
+ query_type="cube",
+ )
+ content = json.loads("".join(content))
+
+ assert status == HTTPStatus.OK
+
+ 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"]
+ )
+
+
+def test_edr_routes_collection_query_geotiff_format(layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any]):
+ """Test the EDR collection query for a GeoTiff formatted reponse.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+
+ single_layer_cube_args : Dict[str, Any]
+ Single layer arguments provided by a test fixture.
+ """
+ collection_id = layers[0].group
+ instance_id = next(iter(layers[0].time_instances()))
+ single_layer_cube_args["f"] = "geotiff"
+ request = mock_request(single_layer_cube_args)
+ edr_routes = EdrRoutes(layers=layers)
+
+ headers, status, _ = edr_routes.collection_query(
+ request,
+ collection_id=collection_id,
+ instance_id=instance_id,
+ query_type="cube",
+ )
+
+ assert status == HTTPStatus.OK
+ assert headers["Content-Disposition"] == f"attachment; filename={layers[0].identifier}.tif"
+
+
+def test_edr_routes_collection_query_invalid_type(layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any]):
+ """Test the EDR collection query with an invalid query type.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+
+ single_layer_cube_args : Dict[str, Any]
+ Single layer arguments provided by a test fixture.
+ """
+ collection_id = layers[0].group
+ instance_id = next(iter(layers[0].time_instances()))
+ request = mock_request(single_layer_cube_args)
+ edr_routes = EdrRoutes(layers=layers)
+
+ _, status, _ = edr_routes.collection_query(
+ request,
+ collection_id=collection_id,
+ instance_id=instance_id,
+ query_type="corridor",
+ )
+
+ assert status == HTTPStatus.BAD_REQUEST
+
+
+def test_edr_routes_collection_query_invalid_bbox(layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any]):
+ """Test the EDR collection query with an invalid bounding box.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+
+ single_layer_cube_args : Dict[str, Any]
+ Single layer arguments provided by a test fixture.
+ """
+ single_layer_cube_args["bbox"] = "invalid"
+ request = mock_request(single_layer_cube_args)
+ edr_routes = EdrRoutes(layers=layers)
+
+ _, status, _ = edr_routes.collection_query(
+ request,
+ collection_id=layers[0].group,
+ instance_id=next(iter(layers[0].time_instances())),
+ query_type="cube",
+ )
+
+ assert status == HTTPStatus.BAD_REQUEST
+
+
+def test_edr_routes_collection_query_missing_parameter(
+ layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any]
+):
+ """Test the EDR colletion query with a missing parameter. All parameters are expected to be returned.
+
+ Parameters
+ ----------
+ layers : List[pogc.Layer]
+ Layers provided by a test fixture.
+
+ single_layer_cube_args : Dict[str, Any]
+ Single layer arguments provided by a test fixture.
+ """
+ del single_layer_cube_args["parameter-name"]
+ request = mock_request(single_layer_cube_args)
+ edr_routes = EdrRoutes(layers=layers)
+
+ _, status, content = edr_routes.collection_query(
+ request,
+ collection_id=layers[0].group,
+ 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}
+
+
+def test_edr_routes_request_url_updates_configuration_url():
+ """Test the EDR routes request base URL updates the configuration URL."""
+ request_url = "http://test:5000/ogc/edr/static/img/logo.png"
+ expected_config_url = "http://test:5000/ogc/edr"
+ request = mock_request({"base_url": request_url})
+ edr_routes = EdrRoutes(layers=[])
+
+ _, status, _ = edr_routes.static_files(request, "img/logo.png")
+
+ 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/ogc_common.py b/ogc/ogc_common.py
index 0341201..4bf3dd4 100755
--- a/ogc/ogc_common.py
+++ b/ogc/ogc_common.py
@@ -1,6 +1,7 @@
import logging
-import lxml, lxml.etree
+import lxml
+import lxml.etree
import numpy as np
import traitlets as tl
@@ -27,9 +28,8 @@ def to_xml(self):
raise NotImplementedError("XML Serialization not implemented.")
def _load_xml_doc(self, xml_doc):
- """ Override this method with code that unpacks contents of XML into the traits object."""
+ """Override this method with code that unpacks contents of XML into the traits object."""
raise NotImplementedError("XML Parsing not implemented.")
- return self
def load_from_kv(self, args):
self._load_from_kv(args)
@@ -72,7 +72,6 @@ def validate(self):
def to_xml(self):
raise NotImplementedError()
- return "%s" % self.value
class TemporalSubset(XMLNode):
@@ -89,17 +88,9 @@ class TemporalSubset(XMLNode):
def validate(self):
raise NotImplementedError()
- for val in (
- self.lower_corner[0],
- self.lower_corner[1],
- self.upper_corner[0],
- self.upper_corner[1],
- ):
- assert np.isfinite(val), "error: time values must be finite"
def to_xml(self):
raise NotImplementedError()
- return "%s" % self.value
class WCSException(Exception):
@@ -112,7 +103,7 @@ def __init__(
"""
exception_code: 'NoApplicableCode', 'InvalidFormat', 'CoverageNotDefined', 'MissingParameterValue', 'InvalidParameterValue'
"""
- super(WCSException, self).__init__(exception_text)
+ super().__init__(exception_text, exception_code, locator)
self.exception_text = exception_text
self.exception_code = exception_code
@@ -133,7 +124,5 @@ def to_xml(self):
{exception_text}
-""".format(
- self=self, exception_text=exception_text
- )
+""".format(self=self, exception_text=exception_text)
return xml
diff --git a/ogc/podpac.py b/ogc/podpac.py
index 2490435..e2103cd 100755
--- a/ogc/podpac.py
+++ b/ogc/podpac.py
@@ -6,17 +6,16 @@
import podpac
from podpac.core.coordinates import Coordinates
import traitlets as tl
-
+from typing import List
from matplotlib import pyplot as plt
import matplotlib as mpl
import io
-from PIL import Image
import numpy as np
-import xarray as xr
import json
import textwrap
import re
-
+from datetime import datetime
+from ogc.settings import EDR_TIME_INSTANCE_DIMENSION
def _uppercase_for_dict_keys(lower_dict):
@@ -49,12 +48,76 @@ class Layer(ogc.Layer):
node = tl.Instance(klass=podpac.Node, allow_none=True)
convert_requests_to_default_crs = tl.Bool(default_value=True)
+ crs84_colon_format = "CRS:84"
def __init__(self, **kwargs):
super().__init__(**kwargs)
if self.node is not None and self.node.style.enumeration_legend:
self._style.is_enumerated = True
+ def time_instances(self) -> List[str]:
+ """Retrieve the time instances available for the layer.
+
+ Returns
+ -------
+ List[str]
+ List of available time instances as a strings.
+ """
+ time_instances = set()
+ coordinates = self.get_coordinates()
+
+ # Time instances are created if a node has a time instance dimension.
+ if coordinates is not None and EDR_TIME_INSTANCE_DIMENSION in coordinates.udims:
+ time_instances.update(
+ [
+ time.astype("datetime64[ms]").astype(datetime).isoformat()
+ for time in coordinates[EDR_TIME_INSTANCE_DIMENSION].coordinates
+ ]
+ )
+
+ return list(time_instances)
+
+ def get_coordinates(self) -> Coordinates | None:
+ """Retrieve the coordinates from the node.
+
+ Returns
+ -------
+ Coordinates | None
+ Coordinates from the node or None if not found.
+ """
+ if self.node is None:
+ return None
+
+ 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.
+
+ Returns
+ -------
+ str | None
+ The units from the node.
+ """
+ units = None
+ if self.node is not None and self.node.units is not None:
+ units = self.node.units
+ elif self.node is not None and self.node.style is not None:
+ units = self.node.style.units
+
+ return units
+
def get_node(self, args):
return self.node
@@ -70,24 +133,18 @@ def get_map(self, args):
orig_w = args["WIDTH"]
# don't reduce # pixels to less than MIN_N (unless orig request was smaller)
MIN_N = 8
- args["HEIGHT"] = max(
- min(int(orig_h), MIN_N), int(round(int(orig_h) / rescale))
- )
- args["WIDTH"] = max(
- min(int(orig_w), MIN_N), int(round(int(orig_w) / rescale))
- )
+ args["HEIGHT"] = max(min(int(orig_h), MIN_N), int(round(int(orig_h) / rescale)))
+ args["WIDTH"] = max(min(int(orig_w), MIN_N), int(round(int(orig_w) / rescale)))
else:
rescale = 0
- if "CRS" in args and args["CRS"].upper() == "CRS:84".upper():
+ if "CRS" in args and args["CRS"].upper() == self.crs84_colon_format.upper():
args["CRS"] = "CRS84" # for pyproj
if self.convert_requests_to_default_crs and "DEFAULT_CRS" in podpac.settings:
# PODPAC transforms input coords to crs of datasource recursively
# every time eval is used in a Node included in its dependency tree.
# This optimization can be used if most datasources are stored in the same crs.
- coords = Coordinates.from_url(args).transform(
- podpac.settings["DEFAULT_CRS"]
- )
+ coords = Coordinates.from_url(args).transform(podpac.settings["DEFAULT_CRS"])
else:
coords = Coordinates.from_url(args)
@@ -98,17 +155,10 @@ def get_map(self, args):
if rescale > 1.0:
args["HEIGHT"] = orig_h
args["WIDTH"] = orig_w
- if (
- self.convert_requests_to_default_crs
- and "DEFAULT_CRS" in podpac.settings
- ):
- rescaledcoords = Coordinates.from_url(args).transform(
- podpac.settings["DEFAULT_CRS"]
- )
+ if self.convert_requests_to_default_crs and "DEFAULT_CRS" in podpac.settings:
+ rescaledcoords = Coordinates.from_url(args).transform(podpac.settings["DEFAULT_CRS"])
else:
rescaledcoords = Coordinates.from_url(args)
- # rescaled_node = podpac.data.Array(source=output, coordinates=coords, style = node.style)
- # output = rescaled_node.eval(rescaledcoords)
output = output.interp(
lat=rescaledcoords["lat"].coordinates + 1e-6,
lon=rescaledcoords["lon"].coordinates + 1e-6,
@@ -122,15 +172,13 @@ def get_map(self, args):
def get_coverage(self, args):
args = _uppercase_for_dict_keys(args)
- if "CRS" in args and args["CRS"].upper() == "CRS:84".upper():
+ if "CRS" in args and args["CRS"].upper() == self.crs84_colon_format.upper():
args["CRS"] = "CRS84" # for pyproj
if self.convert_requests_to_default_crs and "DEFAULT_CRS" in podpac.settings:
# PODPAC transforms input coords to crs of datasource recursively
# every time eval is used in a Node included in its dependency tree.
# This optimization can be used if most datasources are stored in the same crs.
- coords = Coordinates.from_url(args).transform(
- podpac.settings["DEFAULT_CRS"]
- )
+ coords = Coordinates.from_url(args).transform(podpac.settings["DEFAULT_CRS"])
else:
coords = Coordinates.from_url(args)
@@ -184,17 +232,18 @@ class LegendGraphic(tl.HasTraits):
"""
A class for generating legend graphics with adjustable properties such as width, height, fonts, and color mappings.
"""
+
width = tl.Float(default_value=0.7) # inches
max_width = tl.Float(default_value=1.5) # inches
- min_width = tl.Float(default_value=0.8) # inches
+ min_width = tl.Float(default_value=0.8) # inches
height = tl.Float(default_value=2.5) # inches
max_heigth = tl.Float(default_value=5.5) # inches
dpi = tl.Float(default_value=100) # pixels per inch
- units_fontsize = tl.Float(default_value=13) # used for units text at top of legend
- colorbar_fontsize = tl.Float(default_value=10) # used for tick marks of colorbar
- enumeration_fontsize = tl.Float(default_value=16) # used for labels of categorical legends
- enumeration_min_fontsize = tl.Float(default_value=5)# minimum fontsize for categorical legends with lots of labels
- max_unit_chars = tl.Float(default_value=16) # maximum characters allowed in a line for units
+ units_fontsize = tl.Float(default_value=13) # used for units text at top of legend
+ colorbar_fontsize = tl.Float(default_value=10) # used for tick marks of colorbar
+ enumeration_fontsize = tl.Float(default_value=16) # used for labels of categorical legends
+ enumeration_min_fontsize = tl.Float(default_value=5) # minimum fontsize for categorical legends with lots of labels
+ max_unit_chars = tl.Float(default_value=16) # maximum characters allowed in a line for units
units = tl.Unicode(default_value=tl.Undefined, allow_none=True)
img_format = tl.Enum(values=["png", "pdf", "ps", "eps", "svg"], default_value="png")
cmap = tl.Instance(klass=mpl.colors.Colormap, default_value=mpl.cm.viridis)
@@ -205,9 +254,7 @@ class LegendGraphic(tl.HasTraits):
default_value=None,
allow_none=True,
)
- enumeration_colors = tl.Dict(
- key_trait=tl.Int(), default_value=None, allow_none=True
- )
+ enumeration_colors = tl.Dict(key_trait=tl.Int(), default_value=None, allow_none=True)
clim = tl.List(default_value=[None, None])
def __init__(self, *args, **kwargs):
@@ -219,7 +266,7 @@ def __init__(self, *args, **kwargs):
def legend_image(self):
"""
Generates the legend image based on provided parameters.
-
+
Returns:
io.BytesIO: A byte stream containing the legend image in the specified format.
"""
@@ -257,19 +304,21 @@ def check_if_units_need_to_be_wrapped(self, fig):
"""
Checks if the unit text needs wrapping. If so, it divieds the string into bins the
size of max_unit_chars.
-
+
Args:
fig (matplotlib.figure.Figure): The figure object.
-
+
Returns:
tuple: Updated figure, boolean flag indicating wrapping, and the wrapped unit string.
"""
units = "[%s]" % self.units
- needs_wrap = len(units)>self.max_unit_chars #if characters are greater than 16 then wrap text, shrink colorbar
+ needs_wrap = (
+ len(units) > self.max_unit_chars
+ ) # if characters are greater than 16 then wrap text, shrink colorbar
# currently only allows for 2 lines
wrapped_units = self.wrap_text(units, self.max_unit_chars)
# format exponents
- units = re.sub(r"\^(\d+)", r"$^{\1}\!$", units)
+ units = re.sub(r"\^(\d+)", r"$^{\1}\!$", units)
units = re.sub(r"\^-(\d+)", r"$^{-\1}\!$", units)
# add units to figure
fig.text(
@@ -279,28 +328,30 @@ def check_if_units_need_to_be_wrapped(self, fig):
fontsize=self.units_fontsize,
horizontalalignment="center",
verticalalignment="top",
- wrap=True
+ wrap=True,
)
return fig, needs_wrap, wrapped_units
-
+
def adjust_fig_height_for_wrapped_units(self, fig, wrapped_units):
"""
Adjusts figure height to accommodate wrapped units. Will increase figure
size for each line of wrapped text until maximum figure height is reached
-
+
Args:
fig (matplotlib.figure.Figure): The figure object.
wrapped_units (str): The wrapped unit string.
-
+
Returns:
tuple: Updated figure and adjusted axis.
"""
-
+
# wrap text and increase height of figure
added_lines = wrapped_units.count("\n")
added_height = self.additional_height_for_wrapped_text(added_lines, self.units_fontsize)
- fig_height = min(self.max_heigth, added_height + self.height) # add extra height to figure ensure it is less than 6.5 in
- # adjust fig size to fit units
+ fig_height = min(
+ self.max_heigth, added_height + self.height
+ ) # add extra height to figure ensure it is less than 6.5 in
+ # adjust fig size to fit units
fig.set_size_inches(self.max_width, fig_height, forward=True)
# Standard height ratio (before adjustments)
@@ -314,118 +365,122 @@ def adjust_fig_height_for_wrapped_units(self, fig, wrapped_units):
def adjust_fig_width_for_unwrapped_units(self, fig, units_str):
"""
- Adjusts figure width to accommodate units.
+ Adjusts figure width to accommodate units.
Expects units to be under max_unit_chars
-
+
Args:
fig (matplotlib.figure.Figure): The figure object.
wrapped_units (str): The wrapped unit string.
-
+
Returns:
tuple: Updated figure and adjusted axis.
"""
# add space for units
ax = fig.add_axes([0.25, 0.05, 0.15, 0.80])
# Estimates the max label width assuming fontsize 10
- max_label_width_units = self.get_max_text_width([units_str], self.units_fontsize)
-
+ max_label_width_units = self.get_max_text_width([units_str], self.units_fontsize)
+
# add color bar and see if fig width needs to be bigger for tick marks
norm = mpl.colors.Normalize(vmin=self.clim[0], vmax=self.clim[1])
cb = mpl.colorbar.ColorbarBase(ax, cmap=self.cmap, norm=norm)
-
+
# Convert ticks to float32 to avoid errors converting float64 to string
- tick_labels = [str(t) for t in cb.ax.get_yticks().astype('f4')]
+ tick_labels = [str(t) for t in cb.ax.get_yticks().astype("f4")]
max_label_width_ticks = self.get_max_text_width(tick_labels, self.colorbar_fontsize)
- #define minimum width need or max_label width + some extra margin
- fig_width = max(self.min_width, max_label_width_units+0.2, max_label_width_ticks+0.4)
+ # define minimum width need or max_label width + some extra margin
+ fig_width = max(self.min_width, max_label_width_units + 0.2, max_label_width_ticks + 0.4)
fig.set_size_inches(fig_width, self.height, forward=True)
return fig, ax
-
+
def create_enumeration_legend(self, fig, ax):
"""
- Creates a legend for categorical data.
+ Creates a legend for categorical data.
Dynamically adjusts figure size based on number of labels and label text length
Adds colorbar to figure
-
+
Args:
fig (matplotlib.figure.Figure): The figure object.
ax (matplotlib.axes.Axes): The axis object.
-
+
Returns:
matplotlib.figure.Figure: The updated figure.
"""
enum_values = list(self.enumeration_colors.keys())
enum_colors = list(self.enumeration_colors.values())
enum_labels = list(self.enumeration_legend.values())
-
- # Dynamically adjust font size based on the number of ticks
- font_size = max(self.enumeration_min_fontsize, self.enumeration_fontsize - (len(enum_values) * 0.35)) # Scale font size
- # Change legend dynamically
- max_label_width = self.get_max_text_width(enum_labels, font_size) # Estimates the max label width assuming fontsize 10
+ # Dynamically adjust font size based on the number of ticks
+ font_size = max(
+ self.enumeration_min_fontsize, self.enumeration_fontsize - (len(enum_values) * 0.35)
+ ) # Scale font size
+
+ # Change legend dynamically
+ max_label_width = self.get_max_text_width(
+ enum_labels, font_size
+ ) # Estimates the max label width assuming fontsize 10
fig_width = 0.5 + max_label_width # Base width + label-dependent width
fig_height = min(self.max_heigth, len(enum_colors) * 0.25) # Adjust height based on number of labels
fig.set_size_inches(fig_width, fig_height, forward=True)
-
- self.cmap = mpl.colors.ListedColormap(enum_colors) #create categorical colomap to replace previous cmap
- bounds = np.array([val-0.5 for val in np.arange(1,len(enum_values)+2)])
+
+ self.cmap = mpl.colors.ListedColormap(enum_colors) # create categorical colomap to replace previous cmap
+ bounds = np.array([val - 0.5 for val in np.arange(1, len(enum_values) + 2)])
norm = mpl.colors.BoundaryNorm(bounds, self.cmap.N)
cb = mpl.colorbar.ColorbarBase(
ax,
cmap=self.cmap,
norm=norm,
- ticks=np.arange(1,len(self.enumeration_legend)+1),
+ ticks=np.arange(1, len(self.enumeration_legend) + 1),
)
if self.enumeration_legend:
cb.ax.set_yticklabels(enum_labels, fontsize=font_size)
return fig
-
+
def adjust_fig_width_for_long_tick_marks(self, fig, ax):
"""
- Adjusts figure width to accommodate tick marks that are long.
-
+ Adjusts figure width to accommodate tick marks that are long.
+
Args:
fig (matplotlib.figure.Figure): The figure object.
ax (matplotlib.axes.Axes): The axis object.
-
+
Returns:
matplotlib.figure.Figure: The updated figure.
"""
norm = mpl.colors.Normalize(vmin=self.clim[0], vmax=self.clim[1])
cb = mpl.colorbar.ColorbarBase(ax, cmap=self.cmap, norm=norm)
- tick_labels = [str(t) for t in cb.ax.get_yticks().astype('f4')] # Convert ticks to strings
+ tick_labels = [str(t) for t in cb.ax.get_yticks().astype("f4")] # Convert ticks to strings
max_label_width = self.get_max_text_width(tick_labels, self.colorbar_fontsize)
fig_width = max_label_width + 0.4
- fig.set_size_inches(fig_width, self.height,forward=True)
+ fig.set_size_inches(fig_width, self.height, forward=True)
return fig
def get_max_text_width(self, labels, font_size=10):
"""
Estimates the maximum width of given text labels in inches.
-
+
Args:
labels (list of str): List of text labels.
font_size (int): Font size used for labels.
-
+
Returns:
float: The maximum width of the labels in inches.
"""
fig, ax = plt.subplots() # Create a temporary figure
renderer = fig.canvas.get_renderer() # Get renderer to measure text
-
+
text_widths = []
for label in labels:
text = ax.text(0, 0, label, fontsize=font_size) # Attach text to the figure
text_widths.append(text.get_window_extent(renderer).width)
-
+
plt.close(fig) # Close temporary figure
return max(text_widths) / self.dpi # Convert pixels to inches
-
+
def wrap_text(self, text, max_width_chars=16):
"""
Split string into sections of length max_width_chars
@@ -435,16 +490,16 @@ def wrap_text(self, text, max_width_chars=16):
def additional_height_for_wrapped_text(self, added_lines_num, font_size):
"""
Calculates additional height needed for wrapped text based on number lines needed
-
+
Args:
added_lines_num (int): Number of additional wrapped lines.
font_size (int): Font size of the text.
-
+
Returns:
float: Additional height required in inches.
"""
font_height_px = font_size * (self.dpi / 72) # Convert to pixels
font_height_in = font_height_px / self.dpi # Convert pixels to inches
- additional_height = font_height_in*added_lines_num
+ additional_height = font_height_in * added_lines_num
return additional_height
diff --git a/ogc/servers.py b/ogc/servers.py
index 835dd3f..2c1093f 100755
--- a/ogc/servers.py
+++ b/ogc/servers.py
@@ -7,21 +7,36 @@
"""
import re
+import xml.sax.saxutils
from flask import Flask, request, Response, make_response, send_file
import six
-import traceback
import logging
+from typing import Callable
+from werkzeug.datastructures import ImmutableMultiDict
from ogc.ogc_common import WCSException
+from pygeoapi.api import APIRequest
+from pygeoapi.util import get_api_rules
+from . import settings
logger = logging.getLogger(__name__)
+def _check_query_string(raw_qs: bytes) -> None:
+ """Raise WCSException if the raw query string exceeds the maximum allowed length or contains invalid UTF-8."""
+ if len(raw_qs) > settings.MAX_QUERY_STRING_BYTES:
+ raise WCSException("Request query string exceeds maximum allowed length.")
+ try:
+ raw_qs.decode("utf-8")
+ except UnicodeDecodeError:
+ raise WCSException("Request contains invalid UTF-8 encoding.")
+
+
def respond_xml(doc, status=200):
# First, validate that XML can be parsed.
from lxml import etree
- root = etree.fromstring(doc.encode("ascii"))
+ etree.fromstring(doc.encode("ascii"))
# Then, return w/ proper content type
return Response(doc, mimetype="text/xml", status=status)
@@ -30,10 +45,9 @@ def home(endpoint):
"""Example API home page. Developers should make their own page similar to this one."""
test_layer = "testLayerName"
test_layer_time = "12:59:59" # HH:MM:SS
- return f""" OGC Server API
- This is the API endpoint served at {endpoint}. Add example usage here for your users.
-