diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f42f8a6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + branches: + - main + - KDP-1528 + tags: + - '*' + pull_request: +env: + LATEST_PY_VERSION: '3.10' + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11'] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install .["test"] + + - name: Run pre-commit + if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} + run: | + python -m pip install pre-commit + pre-commit run --all-files + + - name: Run tests + run: python -m pytest --cov edr_pydantic --cov-report xml --cov-report term-missing + + - name: Upload Results + if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: unittests + name: ${{ matrix.python-version }} + fail_ci_if_error: false + + publish: + needs: [tests] + runs-on: ubuntu-latest + if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.LATEST_PY_VERSION }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flit + python -m pip install . + + - name: Set tag version + id: tag + run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT + + - name: Set module version + id: module + run: echo "version=$(python -c 'from importlib.metadata import version; print(version("edr_pydantic"))')" >> $GITHUB_OUTPUT + + - name: Build and publish + if: steps.tag.outputs.tag == steps.module.outputs.version + env: + FLIT_USERNAME: ${{ secrets.PYPI_USERNAME }} + FLIT_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: flit publish diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..508cbb8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Code Editors +## Jet Brains +.idea/ +.run/ +__pycache__/ + +# Python +## MyPy +.mypy_cache +## Virtual Environment +venv/ + +# unit test results +.coverage +.pytest_cache +TEST-*-*.xml +coverage.json +coverage.xml +htmlcov/ +junit-report.xml + +# Ignore package +*.egg-info/ +.env +build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ed5aa23 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,67 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + # Formatting + - id: end-of-file-fixer # Makes sure files end in a newline and only a newline. + - id: pretty-format-json + args: [ + '--autofix', + '--indent=4', + '--no-ensure-ascii', + '--no-sort-keys' + ] # Formats and sorts your JSON files. + - id: trailing-whitespace # Trims trailing whitespace. + # Checks + - id: check-json # Attempts to load all json files to verify syntax. + - id: check-merge-conflict # Check for files that contain merge conflict strings. + - id: check-shebang-scripts-are-executable # Checks that scripts with shebangs are executable. + - id: check-yaml + # only checks syntax not load the yaml: + # https://stackoverflow.com/questions/59413979/how-exclude-ref-tag-from-check-yaml-git-hook + args: [ '--unsafe' ] # Parse the yaml files for syntax. + + # reorder-python-imports ~ sort python imports + - repo: https://github.com/asottile/reorder_python_imports + rev: v2.6.0 + hooks: + - id: reorder-python-imports + + # black ~ Formats Python code + - repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black + args: [ + '--line-length=120' + ] + + # flake8 ~ Enforces the Python PEP8 style guide + # Configure the pep8-naming flake plugin to recognise @classmethod, @validator, @root_validator as classmethod. + # Ignore the unused imports (F401) for the __init__ files, the imports are not always used inside the file, + # but used to setup how other files can import it in a more convenient way. + - repo: https://github.com/pycqa/flake8 + rev: 4.0.1 + hooks: + - id: flake8 + args: [ + '--classmethod-decorators=classmethod,validator,root_validator', + '--ignore=E203,W503', + '--max-line-length=120', + '--per-file-ignores=__init__.py:F401' + ] + additional_dependencies: [ 'pep8-naming==0.12.1' ] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.5.1 + hooks: + - id: mypy + language_version: python + # No reason to run if only tests have changed. They intentionally break typing. + exclude: tests/.* + # Pass mypy the entire folder because a change in one file can break others. + args: [--config-file=pyproject.toml, src/] + # Don't pass it the individual filenames because it is already doing the whole folder. + pass_filenames: false + additional_dependencies: + - pydantic diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9a5ffc4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Koninklijk Nederlands Meteorologisch Instituut (KNMI) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cab33ee --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# OGC Enviromental Data Retrieval (EDR) API Pydantic + +This repository contains the edr-pydantic Python package. It provides [Pydantic](https://pydantic-docs.helpmanual.io/) models +for the [OGC Enviromental Data Retrieval (EDR) API](https://ogcapi.ogc.org/edr/). +This can, for example, be used to help develop an EDR API using FastAPI. + +## Install +```shell +pip install edr-pydantic +``` + +Or to install from source: + +```shell +pip install git+https://github.com/KNMI/edr-pydantic.git +``` + +## Usage + +```python +from edr_pydantic.collections import Collection +from edr_pydantic.data_queries import EDRQuery, EDRQueryLink +from edr_pydantic.extent import Extent, Spatial +from edr_pydantic.link import Link +from edr_pydantic.observed_property import ObservedProperty +from edr_pydantic.parameter import Parameter +from edr_pydantic.unit import Unit +from edr_pydantic.variables import Variables + +c = Collection( + id="hrly_obs", + title="Hourly Site Specific observations", + description="Observation data for UK observing sites", + extent=Extent( + spatial=Spatial( + bbox=[ + [-15.0, 48.0, 5.0, 62.0] + ], + crs="WGS84" + ) + ), + links=[ + Link( + href="https://example.org/uk-hourly-site-specific-observations", + rel="service-doc" + ) + ], + data_queries={ + 'position': EDRQuery( + link=EDRQueryLink( + href="https://example.org/edr/collections/hrly_obs/position?coords={coords}", + rel="data", + variables=Variables( + query_type="position", + output_formats=[ + "CoverageJSON" + ] + ) + ) + ) + }, + parameter_names={ + "Wind Direction": Parameter( + unit=Unit( + label="degree true" + ), + observedProperty=ObservedProperty( + id="https://codes.wmo.int/common/quantity-kind/_windDirection", + label="Wind Direction" + ) + ) + } +) + +print(c.model_dump_json(indent=2, exclude_none=True)) +``` + +Will print +```json +{ + "id": "hrly_obs", + "title": "Hourly Site Specific observations", + "description": "Observation data for UK observing sites", + "links": [ + { + "href": "https://example.org/uk-hourly-site-specific-observations", + "rel": "service-doc" + } + ], + "extent": { + "spatial": { + "bbox": [ + [ + -15.0, + 48.0, + 5.0, + 62.0 + ] + ], + "crs": "WGS84" + } + }, + "data_queries": { + "position": { + "link": { + "href": "https://example.org/edr/collections/hrly_obs/position?coords={coords}", + "rel": "data", + "variables": { + "query_type": "position", + "output_formats": [ + "CoverageJSON" + ] + } + } + } + }, + "parameter_names": { + "Wind Direction": { + "type": "Parameter", + "unit": { + "label": "degree true" + }, + "observedProperty": { + "id": "https://codes.wmo.int/common/quantity-kind/_windDirection", + "label": "Wind Direction" + } + } + } +} +``` + +## Contributing + +Make an editable install from within the repository root + +```shell +pip install -e '.[test]' +``` + +### Running tests + +```shell +pytest tests/ +``` + +## Real world usage + +This library is used to build an Environmental Data Retrieval (EDR) API, serving observation data from surface weather data station data from the KNMI. See the [KNMI Data Platform](https://developer.dataplatform.knmi.nl/edr-api). + +## TODOs +Help is wanted in the following areas to fully implement the EDR spec: +* See TODOs in code listing various small inconsistencies in the spec +* In various places there could be more validation on content + +## License + +Apache License, Version 2.0 diff --git a/example.py b/example.py new file mode 100644 index 0000000..6546c73 --- /dev/null +++ b/example.py @@ -0,0 +1,37 @@ +from edr_pydantic.collections import Collection +from edr_pydantic.data_queries import EDRQuery +from edr_pydantic.data_queries import EDRQueryLink +from edr_pydantic.extent import Extent +from edr_pydantic.extent import Spatial +from edr_pydantic.link import Link +from edr_pydantic.observed_property import ObservedProperty +from edr_pydantic.parameter import Parameter +from edr_pydantic.unit import Unit +from edr_pydantic.variables import Variables + +c = Collection( + id="hrly_obs", + title="Hourly Site Specific observations", + description="Observation data for UK observing sites", + extent=Extent(spatial=Spatial(bbox=[[-15.0, 48.0, 5.0, 62.0]], crs="WGS84")), + links=[Link(href="https://example.org/uk-hourly-site-specific-observations", rel="service-doc")], + data_queries={ + "position": EDRQuery( + link=EDRQueryLink( + href="https://example.org/edr/collections/hrly_obs/position?coords={coords}", + rel="data", + variables=Variables(query_type="position", output_formats=["CoverageJSON"]), + ) + ) + }, + parameter_names={ + "Wind Direction": Parameter( + unit=Unit(label="degree true"), + observedProperty=ObservedProperty( + id="https://codes.wmo.int/common/quantity-kind/_windDirection", label="Wind Direction" + ), + ) + }, +) + +print(c.model_dump_json(indent=2, exclude_none=True)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1c007ff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "edr-pydantic" +description = "Pydantic models for OGC Enviromental Data (EDR) API" +readme = "README.md" +requires-python = ">=3.8" +license = {file = "LICENSE"} +authors = [ + {name = "KNMI Data Platform Team", email = "opendata@knmi.nl"}, +] +keywords = ["EDR", "Pydantic"] +classifiers = [ + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering :: GIS", + "Typing :: Typed", +] +version = "0.1.0" +dependencies = ["pydantic>=2.3,<3"] + +[project.optional-dependencies] +test = ["pytest", "pytest-cov"] +dev = ["pre-commit"] + +[project.urls] +Source = "https://github.com/knmi/edr-pydantic" + +[build-system] +requires = ["flit>=3.2,<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "edr_pydantic" + +[tool.flit.sdist] +exclude = [ + "test/", + ".github/", +] + +[tool.mypy] +plugins = [ + "pydantic.mypy" +] + +[tool.pydantic-mypy] +warn_untyped_fields = true diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..3c9fb00 --- /dev/null +++ b/setup.py @@ -0,0 +1,11 @@ +import logging +import os + +import setuptools + +logging.basicConfig() +logger = logging.getLogger(__name__) +logger.setLevel(os.environ.get("LOG_LEVEL", "INFO")) + +if __name__ == "__main__": + setuptools.setup() diff --git a/src/edr_pydantic/__init__.py b/src/edr_pydantic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/edr_pydantic/base_model.py b/src/edr_pydantic/base_model.py new file mode 100644 index 0000000..9832511 --- /dev/null +++ b/src/edr_pydantic/base_model.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel as PydanticBaseModel +from pydantic import ConfigDict + + +class EdrBaseModel(PydanticBaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + extra="forbid", + str_min_length=1, + validate_default=True, + validate_assignment=True, + ) diff --git a/src/edr_pydantic/capabilities.py b/src/edr_pydantic/capabilities.py new file mode 100644 index 0000000..0219dbf --- /dev/null +++ b/src/edr_pydantic/capabilities.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import List +from typing import Optional + +from .base_model import EdrBaseModel +from .link import Link + + +class Provider(EdrBaseModel): + name: Optional[str] = None + url: Optional[str] = None + + +class Contact(EdrBaseModel): + email: Optional[str] = None + phone: Optional[str] = None + fax: Optional[str] = None + hours: Optional[str] = None + instructions: Optional[str] = None + address: Optional[str] = None + postalCode: Optional[str] = None # noqa: N815 + city: Optional[str] = None + stateorprovince: Optional[str] = None + country: Optional[str] = None + + +class LandingPageModel(EdrBaseModel): + links: List[Link] + title: Optional[str] = None + description: Optional[str] = None + keywords: Optional[List[str]] = None + provider: Optional[Provider] = None + contact: Optional[Contact] = None + + +class ConformanceModel(EdrBaseModel): + conformsTo: List[str] # noqa: N815 diff --git a/src/edr_pydantic/collections.py b/src/edr_pydantic/collections.py new file mode 100644 index 0000000..a01b11a --- /dev/null +++ b/src/edr_pydantic/collections.py @@ -0,0 +1,40 @@ +from typing import Dict +from typing import List +from typing import Optional + +from .base_model import EdrBaseModel +from .data_queries import DataQueries +from .extent import Extent +from .link import Link +from .parameter import Parameter + + +class Collection(EdrBaseModel): + id: str + title: Optional[str] = None + description: Optional[str] = None + keywords: Optional[List[str]] = None + links: List[Link] + extent: Extent + data_queries: Optional[DataQueries] = None + # TODO According to req A.13 it shall be CRS object, according to C.1 it is a string array + crs: Optional[List[str]] = None + output_formats: Optional[List[str]] = None + parameter_names: Dict[str, Parameter] + # TODO According to req A.13 may have distanceunits. If radius is in link, it shall have distanceunits + distanceunits: Optional[List[str]] = None + + +class Collections(EdrBaseModel): + links: List[Link] + collections: List[Collection] + + +# For now, the instance metadata corresponds to the first collection metadata. So they have equal classes +class Instance(Collection): + pass + + +class Instances(EdrBaseModel): + links: List[Link] + instances: List[Instance] diff --git a/src/edr_pydantic/data_queries.py b/src/edr_pydantic/data_queries.py new file mode 100644 index 0000000..2aceaf3 --- /dev/null +++ b/src/edr_pydantic/data_queries.py @@ -0,0 +1,21 @@ +from typing import Optional + +from .base_model import EdrBaseModel +from .link import EDRQueryLink + + +class EDRQuery(EdrBaseModel): + link: EDRQueryLink + + +class DataQueries(EdrBaseModel): + position: Optional[EDRQuery] = None + radius: Optional[EDRQuery] = None + area: Optional[EDRQuery] = None + cube: Optional[EDRQuery] = None + trajectory: Optional[EDRQuery] = None + corridor: Optional[EDRQuery] = None + locations: Optional[EDRQuery] = None + items: Optional[EDRQuery] = None + # TODO Separate Instance and Collection objects + instances: Optional[EDRQuery] = None diff --git a/src/edr_pydantic/extent.py b/src/edr_pydantic/extent.py new file mode 100644 index 0000000..43668a0 --- /dev/null +++ b/src/edr_pydantic/extent.py @@ -0,0 +1,37 @@ +from typing import List +from typing import Optional + +from pydantic import AwareDatetime + +from .base_model import EdrBaseModel + + +class Spatial(EdrBaseModel): + bbox: List[List[float]] + crs: str + + +class Temporal(EdrBaseModel): + interval: List[List[AwareDatetime]] + values: List[str] + trs: str + + +class Vertical(EdrBaseModel): + interval: List[List[str]] + values: List[str] + vrs: str + + +class Custom(EdrBaseModel): + interval: List[str] + id: str + values: List[str] + reference: Optional[str] = None + + +class Extent(EdrBaseModel): + spatial: Spatial + temporal: Optional[Temporal] = None + vertical: Optional[Vertical] = None + custom: Optional[List[Custom]] = None diff --git a/src/edr_pydantic/link.py b/src/edr_pydantic/link.py new file mode 100644 index 0000000..ce7efb4 --- /dev/null +++ b/src/edr_pydantic/link.py @@ -0,0 +1,26 @@ +from typing import Optional +from typing import Union + +from .base_model import EdrBaseModel +from .variables import CorridorVariables +from .variables import CubeVariables +from .variables import ItemVariables +from .variables import RadiusVariables +from .variables import Variables + + +class Link(EdrBaseModel): + href: str + hreflang: Optional[str] = None + rel: str + # TODO According to A.21 & A.23 all links shall include type + type: Optional[str] = None + title: Optional[str] = None + length: Optional[int] = None + templated: Optional[bool] = None + variables: Optional[Union[Variables, CubeVariables, CorridorVariables, ItemVariables, RadiusVariables]] = None + + +# For EDRQueryLink the variables element is required unlike in other Link objects +class EDRQueryLink(Link, extra="allow"): + variables: Union[Variables, CubeVariables, CorridorVariables, ItemVariables, RadiusVariables] diff --git a/src/edr_pydantic/observed_property.py b/src/edr_pydantic/observed_property.py new file mode 100644 index 0000000..ac7a4f5 --- /dev/null +++ b/src/edr_pydantic/observed_property.py @@ -0,0 +1,17 @@ +from typing import List +from typing import Optional + +from .base_model import EdrBaseModel + + +class Category(EdrBaseModel): + id: str + label: str + description: Optional[str] = None + + +class ObservedProperty(EdrBaseModel): + id: Optional[str] = None + label: str + description: Optional[str] = None + categories: Optional[List[Category]] = None diff --git a/src/edr_pydantic/parameter.py b/src/edr_pydantic/parameter.py new file mode 100644 index 0000000..6a4b9b6 --- /dev/null +++ b/src/edr_pydantic/parameter.py @@ -0,0 +1,45 @@ +from typing import List +from typing import Literal +from typing import Optional + +from pydantic import model_validator + +from .base_model import EdrBaseModel +from .observed_property import ObservedProperty +from .unit import Unit + + +class Parameter(EdrBaseModel, extra="allow"): + type: Literal["Parameter"] = "Parameter" + id: Optional[str] = None + label: Optional[str] = None + description: Optional[str] = None + unit: Optional[Unit] = None + observedProperty: ObservedProperty # noqa: N815 + + @model_validator(mode="after") + def must_not_have_unit_if_observed_property_has_categories(self): + if self.unit is not None and self.observedProperty is not None and self.observedProperty.categories is not None: + raise ValueError( + "A parameter object MUST NOT have a 'unit' member " + "if the 'observedProperty' member has a 'categories' member." + ) + + return self + + +class ParameterGroup(EdrBaseModel, extra="allow"): + type: Literal["ParameterGroup"] = "ParameterGroup" + id: Optional[str] = None + label: Optional[str] = None + description: Optional[str] = None + observedProperty: Optional[ObservedProperty] = None # noqa: N815 + members: List[str] + + @model_validator(mode="after") + def must_have_label_and_or_observed_property(self): + if self.label is None and self.observedProperty is None: + raise ValueError( + "A parameter group object MUST have either or both the members 'label' or/and 'observedProperty'" + ) + return self diff --git a/src/edr_pydantic/py.typed b/src/edr_pydantic/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/edr_pydantic/unit.py b/src/edr_pydantic/unit.py new file mode 100644 index 0000000..08110a2 --- /dev/null +++ b/src/edr_pydantic/unit.py @@ -0,0 +1,24 @@ +from typing import Optional +from typing import Union + +from pydantic import model_validator + +from .base_model import EdrBaseModel + + +class Symbol(EdrBaseModel, extra="allow"): + value: str + type: str + + +class Unit(EdrBaseModel): + id: Optional[str] = None + label: Optional[str] = None + symbol: Optional[Union[str, Symbol]] = None + + @model_validator(mode="after") + def check_either_label_or_symbol(self): + if self.label is None and self.symbol is None: + raise ValueError("Either 'label' or 'symbol' should be set") + + return self diff --git a/src/edr_pydantic/variables.py b/src/edr_pydantic/variables.py new file mode 100644 index 0000000..c18f1d1 --- /dev/null +++ b/src/edr_pydantic/variables.py @@ -0,0 +1,31 @@ +from typing import List +from typing import Optional + +from .base_model import EdrBaseModel + + +class Variables(EdrBaseModel, extra="allow"): + # TODO query_type required? Not according to C.3 + query_type: str + output_formats: Optional[List[str]] = None + # TODO If a default_output_format property exists the defined value MUST be a value contained either in the + # output_formats defined in the variables section or in the parent collection output_formats. + default_output_format: Optional[str] = None + + +class CubeVariables(Variables): + height_units: List[str] + + +class RadiusVariables(Variables): + within_units: List[str] + + +class CorridorVariables(Variables): + height_units: List[str] + within_units: List[str] + + +class ItemVariables(Variables): + # TODO: Is unclear what is meant with Requirement A.20 statement A + pass diff --git a/tests/test_data/doc-example-collections.json b/tests/test_data/doc-example-collections.json new file mode 100644 index 0000000..2864abb --- /dev/null +++ b/tests/test_data/doc-example-collections.json @@ -0,0 +1,719 @@ +{ + "links": [ + { + "href": "https://example.org/edr/collections/", + "hreflang": "en", + "rel": "self", + "type": "application/json" + }, + { + "href": "https://example.org/edr/collections/", + "hreflang": "en", + "rel": "alternate", + "type": "text/html" + }, + { + "href": "https://example.org/edr/collections/", + "hreflang": "en", + "rel": "alternate", + "type": "application/xml" + } + ], + "collections": [ + { + "id": "hrly_obs", + "title": "Hourly Site Specific observations", + "description": "Observation data for UK observing sites", + "keywords": [ + "Wind Direction", + "Wind Speed", + "Wind Gust", + "Air Temperature", + "Weather", + "Relative Humidity", + "Dew point", + "Pressure", + "Pressure Tendancy", + "Visibility" + ], + "links": [ + { + "href": "https://example.org/uk-hourly-site-specific-observations", + "hreflang": "en", + "rel": "service-doc", + "type": "text/html" + }, + { + "href": "https://example.org/terms-and-conditions---datapoint#datalicence", + "hreflang": "en", + "rel": "license", + "type": "text/html" + }, + { + "href": "https://example.org/services/data/terms-and-conditions---datapoint#termsofservice", + "hreflang": "en", + "rel": "restrictions", + "type": "text/html" + } + ], + "extent": { + "spatial": { + "bbox": [ + [ + -15.0, + 48.0, + 5.0, + 62.0 + ] + ], + "crs": "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.01745329251994328,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]]" + }, + "temporal": { + "interval": [ + [ + "2020-04-19T11:00:00Z", + "2020-06-30T09:00:00Z" + ] + ], + "values": [ + "2020-04-19T11:00:00Z/2020-06-30T09:00:00Z" + ], + "trs": "TIMECRS[\"DateTime\",TDATUM[\"Gregorian Calendar\"],CS[TemporalDateTime,1],AXIS[\"Time (T)\",future]]" + } + }, + "data_queries": { + "position": { + "link": { + "href": "https://example.org/edr/collections/hrly_obs/position?coords={coords}", + "rel": "data", + "variables": { + "query_type": "position", + "output_formats": [ + "CoverageJSON", + "GeoJSON", + "IWXXM" + ], + "default_output_format": "IWXXM", + "crs_details": [ + { + "crs": "CRS84", + "wkt": "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.01745329251994328,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]]" + } + ] + } + } + }, + "radius": { + "link": { + "href": "https://example.org/edr/collections/hrly_obs/radius?coords={coords}", + "rel": "data", + "variables": { + "query_type": "radius", + "output_formats": [ + "CoverageJSON", + "GeoJSON", + "IWXXM" + ], + "default_output_format": "GeoJSON", + "within_units": [ + "km", + "miles" + ], + "crs_details": [ + { + "crs": "CRS84", + "wkt": "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.01745329251994328,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]]" + } + ] + } + } + }, + "area": { + "link": { + "href": "https://example.org/edr/collections/hrly_obs/area?coords={coords}", + "rel": "data", + "variables": { + "query_type": "area", + "output_formats": [ + "CoverageJSON", + "GeoJSON", + "BUFR", + "IWXXM" + ], + "default_output_format": "CoverageJSON", + "crs_details": [ + { + "crs": "CRS84", + "wkt": "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.01745329251994328,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]]" + } + ] + } + } + }, + "locations": { + "link": { + "href": "https://example.org/edr/collections/hrly_obs/locations", + "rel": "data", + "variables": { + "query_type": "locations", + "output_formats": [ + "CoverageJSON", + "GeoJSON", + "BUFR", + "IWXXM" + ], + "default_output_format": "CoverageJSON", + "crs_details": [ + { + "crs": "CRS84", + "wkt": "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.01745329251994328,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]]" + } + ] + } + } + } + }, + "crs": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + ], + "output_formats": [ + "CoverageJSON", + "GeoJSON", + "IWXXM" + ], + "parameter_names": { + "Wind Direction": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "degree true", + "symbol": { + "value": "°", + "type": "https://example.org/edr/metadata/units/degree" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/common/quantity-kind/_windDirection", + "label": "Wind Direction" + }, + "measurementType": { + "method": "mean", + "period": "-PT10M/PT0M" + } + }, + "Wind Speed": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "mph", + "symbol": { + "value": "mph", + "type": "https://example.org/edr/metadata/units/mph" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/common/quantity-kind/_windSpeed", + "label": "Wind Speed" + }, + "measurementType": { + "method": "mean", + "period": "-PT10M/PT0M" + } + }, + "Wind Gust": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "mph", + "symbol": { + "value": "mph", + "type": "https://example.org/edr/metadata/units/mph" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/common/quantity-kind/_maximumWindGustSpeed", + "label": "Wind Gust" + }, + "measurementType": { + "method": "maximum", + "period": "-PT10M/PT0M" + } + }, + "Air Temperature": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "degC", + "symbol": { + "value": "°C", + "type": "https://example.org/edr/metadata/units/degC" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/common/quantity-kind/_airTemperature", + "label": "Air Temperature" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + }, + "Weather": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "weather", + "symbol": { + "value": "-", + "type": "https://example.org/edr/metadata/lookup/mo_dp_weather" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/wmdr/ObservedVariableAtmosphere/_266", + "label": "Weather" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + }, + "Relative Humidity": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "percent", + "symbol": { + "value": "%", + "type": "https://example.org/edr/metadata/units/percent" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/bufr4/b/13/_009", + "label": "Relative Humidity" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + }, + "Dew point": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "degC", + "symbol": { + "value": "°C", + "type": "https://example.org/edr/metadata/units/degC" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/common/quantity-kind/_dewPointTemperature", + "label": "Dew point" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + }, + "Pressure": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "hPa", + "symbol": { + "value": "hPa", + "type": "https://example.org/edr/metadata/units/hPa" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/bufr4/b/10/_051", + "label": "Pressure" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + }, + "Pressure Tendancy": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "tendency", + "symbol": { + "value": "-", + "type": "https://example.org/edr/metadata/units/hPa" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/common/quantity-kind/_pressureTendency", + "label": "Pressure Tendancy" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + }, + "Visibility": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "m", + "symbol": { + "value": "m", + "type": "https://example.org/edr/metadata/units/m" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/common/quantity-kind/_horizontalVisibility", + "label": "Visibility" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + } + } + }, + { + "id": "3_hrly_forecast", + "title": "UK 3 Hourly Site Specific Forecast", + "description": "Five day site specific forecast for 6000 UK locations", + "keywords": [ + "Wind Direction", + "Wind Speed", + "Wind Gust", + "Air Temperature", + "Weather", + "Relative Humidity", + "Feels like temperature", + "UV index", + "Probability of precipitation", + "Visibility" + ], + "links": [ + { + "href": "https://example.org/uk-3-hourly-site-specific-forecast", + "hreflang": "en", + "rel": "service-doc", + "type": "text/html" + }, + { + "href": "https://example.org/terms-and-conditions---datapoint#datalicence", + "hreflang": "en", + "rel": "licence", + "type": "text/html" + }, + { + "href": "https://example.org/terms-and-conditions---datapoint#termsofservice", + "hreflang": "en", + "rel": "restrictions", + "type": "text/html" + }, + { + "href": "https://example.org/edr/collections/3_hrly_fcst/instances", + "hreflang": "en", + "rel": "collection" + } + ], + "extent": { + "spatial": { + "bbox": [ + [ + -15.0, + 48.0, + 5.0, + 62.0 + ] + ], + "crs": "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.01745329251994328,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]]" + }, + "temporal": { + "interval": [ + [ + "2020-06-23T18:00:00Z", + "2020-07-04T21:00:00Z" + ] + ], + "values": [ + "2020-06-23T18:00:00Z/2020-07-04T21:00:00Z" + ], + "trs": "TIMECRS[\"DateTime\",TDATUM[\"Gregorian Calendar\"],CS[TemporalDateTime,1],AXIS[\"Time (T)\",future]]" + } + }, + "data_queries": { + "position": { + "link": { + "href": "https://example.org/edr/collections/3_hrly_forecast/position?coords={coords}", + "rel": "data", + "variables": { + "query_type": "position", + "output_formats": [ + "CoverageJSON", + "GeoJSON" + ], + "default_output_format": "IWXXM", + "crs_details": [ + { + "crs": "CRS84", + "wkt": "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.01745329251994328,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]]" + } + ] + } + } + }, + "radius": { + "link": { + "href": "https://example.org/edr/collections/3_hrly_forecast/radius?coords={coords}", + "rel": "data", + "variables": { + "query_type": "radius", + "output_formats": [ + "CoverageJSON", + "GeoJSON" + ], + "default_output_format": "GeoJSON", + "within_units": [ + "km", + "miles" + ], + "crs_details": [ + { + "crs": "CRS84", + "wkt": "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.01745329251994328,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]]" + } + ] + } + } + }, + "area": { + "link": { + "href": "https://example.org/edr/collections/3_hrly_forecast/area?coords={coords}", + "rel": "data", + "variables": { + "query_type": "area", + "output_formats": [ + "CoverageJSON", + "GeoJSON" + ], + "default_output_format": "CoverageJSON", + "crs_details": [ + { + "crs": "CRS84", + "wkt": "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.01745329251994328,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]]" + } + ] + } + } + }, + "instances": { + "link": { + "href": "https://example.org/edr/collections/3_hrly_forecast/instances", + "rel": "data", + "variables": { + "query_type": "instances" + } + } + } + }, + "crs": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + ], + "output_formats": [ + "CoverageJSON", + "GeoJSON" + ], + "parameter_names": { + "Wind Direction": { + "type": "Parameter", + "description": "Direction wind is from", + "unit": { + "label": "degree true", + "symbol": { + "value": "°", + "type": "https://example.org/edr/metadata/units/degree" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/grib2/codeflag/4.2/_0-2-0", + "label": "Wind Direction" + }, + "measurementType": { + "method": "mean", + "period": "-PT10M/PT0M" + } + }, + "Wind Speed": { + "type": "Parameter", + "description": "Average wind speed", + "unit": { + "label": "mph", + "symbol": { + "value": "mph", + "type": "https://example.org/edr/metadata/units/mph" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/grib2/codeflag/4.2/_0-2-1", + "label": "Wind Speed" + }, + "measurementType": { + "method": "mean", + "period": "-PT10M/PT0M" + } + }, + "Wind Gust": { + "type": "Parameter", + "description": "Wind gusts are a rapid increase in strength of the wind relative to the wind speed.", + "unit": { + "label": "mph", + "symbol": { + "value": "mph", + "type": "https://example.org/edr/metadata/units/mph" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/grib2/codeflag/4.2/_0-2-1", + "label": "Wind Gust" + }, + "measurementType": { + "method": "maximum", + "period": "-PT10M/PT0M" + } + }, + "Air Temperature": { + "type": "Parameter", + "description": "2m air temperature in the shade and out of the wind", + "unit": { + "label": "degC", + "symbol": { + "value": "°C", + "type": "https://example.org/edr/metadata/units/degC" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/common/quantity-kind/_airTemperature", + "label": "Air Temperature" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + }, + "Weather": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "weather", + "symbol": { + "value": "-", + "type": "https://example.org/edr/metadata/lookup/mo_dp_weather" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/wmdr/ObservedVariableAtmosphere/_266", + "label": "Weather" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + }, + "Relative Humidity": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "percent", + "symbol": { + "value": "%", + "type": "https://example.org/edr/metadata/units/percent" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/grib2/codeflag/4.2/_0-1-1", + "label": "Relative Humidity" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + }, + "Feels like temperature": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "degC", + "symbol": { + "value": "°C", + "type": "https://example.org/edr/metadata/units/degC" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/common/quantity-kind/_airTemperature", + "label": "Feels like temperature" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + }, + "UV index": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "UV_index", + "symbol": { + "value": "-", + "type": "https://example.org/edr/metadata/lookup/mo_dp_uv" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/grib2/codeflag/4.2/_0-4-51", + "label": "UV index" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + }, + "Probability of precipitation": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "percent", + "symbol": { + "value": "%", + "type": "https://example.org/edr/metadata/units/percent" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/grib2/codeflag/4.2/_0-1-1", + "label": "Probability of precipitation" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + }, + "Visibility": { + "type": "Parameter", + "description": "-", + "unit": { + "label": "quality", + "symbol": { + "value": "-", + "type": "https://example.org/edr/metadata/lookup/mo_dp_visibility" + } + }, + "observedProperty": { + "id": "http://codes.wmo.int/common/quantity-kind/_horizontalVisibility", + "label": "Visibility" + }, + "measurementType": { + "method": "instantaneous", + "period": "PT0M" + } + } + } + } + ] +} diff --git a/tests/test_data/knmi-example-collections.json b/tests/test_data/knmi-example-collections.json new file mode 100644 index 0000000..1c4c51f --- /dev/null +++ b/tests/test_data/knmi-example-collections.json @@ -0,0 +1,527 @@ +{ + "links": [ + { + "href": "https://api.dataplatform.knmi.nl/edr/collections", + "rel": "self" + } + ], + "collections": [ + { + "id": "observations", + "links": [ + { + "href": "https://api.dataplatform.knmi.nl/edr/collections/observations", + "rel": "self" + } + ], + "extent": { + "spatial": { + "bbox": [ + [ + -68.2758, + 12.13, + 7.1492, + 55.3992 + ] + ], + "crs": "WGS84" + }, + "temporal": { + "interval": [ + [ + "2003-04-01T00:10:00Z", + "2023-10-06T10:40:00Z" + ] + ], + "values": [ + "2003-04-01T00:10:00Z/2023-10-06T10:40:00Z" + ], + "trs": "datetime" + }, + "vertical": { + "interval": [ + [ + "-5.77", + "112.72" + ] + ], + "values": [ + "-5.0", + "20.0", + "45.0", + "70.0", + "95.0" + ], + "vrs": "meters" + } + }, + "data_queries": { + "position": { + "link": { + "href": "https://api.dataplatform.knmi.nl/edr/collections/observations/position", + "rel": "data", + "variables": { + "query_type": "position", + "output_formats": [ + "CoverageJSON" + ] + } + } + }, + "cube": { + "link": { + "href": "https://api.dataplatform.knmi.nl/edr/collections/observations/cube", + "rel": "data", + "variables": { + "query_type": "cube", + "output_formats": [ + "CoverageJSON" + ], + "height_units": "m" + } + } + }, + "locations": { + "link": { + "href": "https://api.dataplatform.knmi.nl/edr/collections/observations/locations", + "rel": "data", + "variables": { + "query_type": "locations", + "output_formats": [ + "CoverageJSON" + ] + } + } + } + }, + "crs": [ + "WGS84" + ], + "output_formats": [ + "CoverageJSON" + ], + "parameter_names": { + "dd_10": { + "type": "Parameter", + "description": "Wind, direction, average, height sensor, 10'", + "unit": { + "label": "degree", + "symbol": { + "value": "°", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/wind_from_direction/", + "label": "Wind from direction" + } + }, + "ff_10m_10": { + "type": "Parameter", + "description": "Wind, speed, average, converted to 10 metres, 10'", + "unit": { + "label": "metre per second", + "symbol": { + "value": "m/s", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/wind_speed/", + "label": "Wind speed" + } + }, + "fx_10m_10": { + "type": "Parameter", + "description": "Wind, speed, actual maximum, converted to 10 metres, 10'", + "unit": { + "label": "metre per second", + "symbol": { + "value": "m/s", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/wind_speed_of_gust/", + "label": "Wind speed of gust" + } + }, + "p_nap_msl_10": { + "type": "Parameter", + "description": "Air pressure, converted to MSL or NAP, 10'", + "unit": { + "label": "hectopascal", + "symbol": { + "value": "hPa", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/air_pressure_at_sea_level/", + "label": "Air pressure at sea level" + } + }, + "mor_10": { + "type": "Parameter", + "description": "Visibility, meteorological day vision, 10'", + "unit": { + "label": "metre", + "symbol": { + "value": "m", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/visibility_in_air/", + "label": "Visibility in air" + } + }, + "h_ceilom_10": { + "type": "Parameter", + "description": "Clouds, the height of the base of the first layer, 10'", + "unit": { + "label": "foot", + "symbol": { + "value": "ft", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/cloud_base_altitude/", + "label": "Cloud base altitude" + } + }, + "h1_ceilom_10": { + "type": "Parameter", + "description": "Clouds, the height of the base of the first layer (ceilometer), 10'", + "unit": { + "label": "foot", + "symbol": { + "value": "ft", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/cloud_base_altitude/", + "label": "Cloud base altitude first layer" + } + }, + "h2_ceilom_10": { + "type": "Parameter", + "description": "Clouds, the height of the base of the second layer (ceilometer), 10'", + "unit": { + "label": "foot", + "symbol": { + "value": "ft", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/cloud_base_altitude/", + "label": "Cloud base altitude second layer" + } + }, + "h3_ceilom_10": { + "type": "Parameter", + "description": "Clouds, the height of the base of the third layer (ceilometer), 10'", + "unit": { + "label": "foot", + "symbol": { + "value": "ft", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/cloud_base_altitude/", + "label": "Cloud base altitude third layer" + } + }, + "n_ceilom_10": { + "type": "Parameter", + "description": "Clouds, total degree of coverage (ceilometer), 10'", + "unit": { + "label": "the unit of cloud amount – okta – is an eighth of the sky dome covered by cloud", + "symbol": { + "value": "okta", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/cloud_area_fraction/", + "label": "Cloud area fraction" + } + }, + "n1_ceilom_10": { + "type": "Parameter", + "description": "Clouds, degree of coverage first layer (ceilometer), 10'", + "unit": { + "label": "the unit of cloud amount – okta – is an eighth of the sky dome covered by cloud", + "symbol": { + "value": "okta", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/cloud_area_fraction/", + "label": "Cloud area fraction first layer" + } + }, + "n2_ceilom_10": { + "type": "Parameter", + "description": "Clouds, degree of coverage second layer (ceilometer), 10'", + "unit": { + "label": "the unit of cloud amount – okta – is an eighth of the sky dome covered by cloud", + "symbol": { + "value": "okta", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/cloud_area_fraction/", + "label": "Cloud area fraction second layer" + } + }, + "n3_ceilom_10": { + "type": "Parameter", + "description": "Clouds, degree of coverage third layer (ceilometer), 10'", + "unit": { + "label": "the unit of cloud amount – okta – is an eighth of the sky dome covered by cloud", + "symbol": { + "value": "okta", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/cloud_area_fraction/", + "label": "Cloud area fraction third layer" + } + }, + "dr_pws_10": { + "type": "Parameter", + "description": "Precipitation, duration, present weather sensor, 10'", + "unit": { + "label": "second", + "symbol": { + "value": "s", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "precipitation_duration", + "label": "Precipitation duration" + } + }, + "dr_regenm_10": { + "type": "Parameter", + "description": "Precipitation, duration, electric rain gauge, 10'", + "unit": { + "label": "second", + "symbol": { + "value": "s", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "precipitation_duration", + "label": "Precipitation duration electric rain gauge" + } + }, + "ri_pws_10": { + "type": "Parameter", + "description": "Precipitation, intensity, present weather sensor, 10'", + "unit": { + "label": "millimetre per hour", + "symbol": { + "value": "mm/h", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/lwe_precipitation_rate/", + "label": "Precipitation rate" + } + }, + "ri_regenm_10": { + "type": "Parameter", + "description": "Precipitation, intensity, electric rain gauge, 10'", + "unit": { + "label": "millimetre per hour", + "symbol": { + "value": "mm/h", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/lwe_precipitation_rate/", + "label": "Precipitation rate electric rain gauge" + } + }, + "q_glob_10": { + "type": "Parameter", + "description": "Radiation, global, average, 10'", + "unit": { + "label": "watt per square metre", + "symbol": { + "value": "W/m²", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/downwelling_shortwave_flux_in_air/", + "label": "Radiation" + } + }, + "sq_10": { + "type": "Parameter", + "description": "Sunshine duration, duration derived from radiation, 10'", + "unit": { + "label": "minute", + "symbol": { + "value": "min", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/duration_of_sunshine/", + "label": "Sunshine duration" + } + }, + "tn_10cm_past_6h_10": { + "type": "Parameter", + "description": "Temperature, air, minimum, 0.1 metre, 10'", + "unit": { + "label": "degree Celsius", + "symbol": { + "value": "°C", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/air_temperature/", + "label": "Air temperature minimum, 0.1 metre" + } + }, + "t_dewp_10": { + "type": "Parameter", + "description": "Temperature air dewpoint 10'", + "unit": { + "label": "degree Celsius", + "symbol": { + "value": "°C", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/dew_point_temperature/", + "label": "Dew point temperature" + } + }, + "t_dryb_10": { + "type": "Parameter", + "description": "Temperature air 10'", + "unit": { + "label": "degree Celsius", + "symbol": { + "value": "°C", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/air_temperature/", + "label": "Air temperature" + } + }, + "tn_dryb_10": { + "type": "Parameter", + "description": "Temperature, air, minimum, 10'", + "unit": { + "label": "degree Celsius", + "symbol": { + "value": "°C", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/air_temperature/", + "label": "Air temperature minimum" + } + }, + "tx_dryb_10": { + "type": "Parameter", + "description": "Temperature, air, maximum, 10'", + "unit": { + "label": "degree Celsius", + "symbol": { + "value": "°C", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/air_temperature/", + "label": "Air temperature maximum" + } + }, + "u_10": { + "type": "Parameter", + "description": "Humidity air relative 10'", + "unit": { + "label": "percent", + "symbol": { + "value": "%", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "https://vocab.nerc.ac.uk/standard_name/relative_humidity/", + "label": "Relative humidity" + } + }, + "ww_cor_10": { + "type": "Parameter", + "description": "Weather, validated code, present weather sensor, 10'", + "unit": { + "label": "weather code", + "symbol": { + "value": "code", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "KNMI: Handboek waarnemingen", + "label": "Weather code (validated)" + } + }, + "ww_curr_10": { + "type": "Parameter", + "description": "Weather, code, present weather sensor, 10'", + "unit": { + "label": "weather code", + "symbol": { + "value": "code", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "WMO table 4680", + "label": "Weather code" + } + }, + "ww_past_10": { + "type": "Parameter", + "description": "Weather, code previous 10', 10'", + "unit": { + "label": "weather code", + "symbol": { + "value": "code", + "type": "http://www.opengis.net/def/uom/UCUM/" + } + }, + "observedProperty": { + "id": "WMO table 4680", + "label": "Weather code, previous 10'" + } + } + } + } + ] +} diff --git a/tests/test_data/label-or-symbol-unit.json b/tests/test_data/label-or-symbol-unit.json new file mode 100644 index 0000000..b0d6af4 --- /dev/null +++ b/tests/test_data/label-or-symbol-unit.json @@ -0,0 +1,3 @@ +{ + "id": "foo" +} diff --git a/tests/test_data/landing-page.json b/tests/test_data/landing-page.json new file mode 100644 index 0000000..40cc3a2 --- /dev/null +++ b/tests/test_data/landing-page.json @@ -0,0 +1,47 @@ +{ + "links": [ + { + "href": "https://api.dataplatform.knmi.nl/edr/", + "rel": "self", + "title": "Landing Page in JSON" + }, + { + "href": "https://api.dataplatform.knmi.nl/edr/docs", + "rel": "service-desc", + "title": "API description in HTML" + }, + { + "href": "https://api.dataplatform.knmi.nl/edr/openapi.json", + "rel": "service-desc", + "title": "API description in JSON" + }, + { + "href": "https://api.dataplatform.knmi.nl/edr/conformance", + "rel": "data", + "title": "Conformance Declaration in JSON" + }, + { + "href": "https://api.dataplatform.knmi.nl/edr/collections", + "rel": "data", + "title": "Collections metadata in JSON" + } + ], + "title": "Observations API EDR", + "description": "The observation api in EDR format from the KNMI", + "keywords": [ + "Weather", + "Temperature", + "Wind", + "Humidity", + "Pressure", + "Clouds", + "Radiation" + ], + "provider": { + "name": "KNMI - Koninklijk Nederlands Meteorologisch Instituut", + "url": "dataplatform.knmi.nl" + }, + "contact": { + "email": "opendata@knmi.nl" + } +} diff --git a/tests/test_data/simple-instance.json b/tests/test_data/simple-instance.json new file mode 100644 index 0000000..b8a7b9a --- /dev/null +++ b/tests/test_data/simple-instance.json @@ -0,0 +1,35 @@ +{ + "id": "hrly_obs", + "title": "Hourly Site Specific observations", + "links": [ + { + "href": "https://example.org/uk-hourly-site-specific-observations", + "rel": "service-doc" + } + ], + "extent": { + "spatial": { + "bbox": [ + [ + -15.0, + 48.0, + 5.0, + 62.0 + ] + ], + "crs": "WGS84" + } + }, + "parameter_names": { + "Wind Direction": { + "type": "Parameter", + "unit": { + "label": "degree true" + }, + "observedProperty": { + "id": "https://codes.wmo.int/common/quantity-kind/_windDirection", + "label": "Wind Direction" + } + } + } +} diff --git a/tests/test_edr.py b/tests/test_edr.py new file mode 100644 index 0000000..0376f71 --- /dev/null +++ b/tests/test_edr.py @@ -0,0 +1,43 @@ +import json +from pathlib import Path + +import pytest +from edr_pydantic.capabilities import LandingPageModel +from edr_pydantic.collections import Collections +from edr_pydantic.collections import Instance +from edr_pydantic.unit import Unit +from pydantic import ValidationError + +happy_cases = [ + ("knmi-example-collections.json", Collections), + ("doc-example-collections.json", Collections), + ("simple-instance.json", Instance), + ("landing-page.json", LandingPageModel), +] + + +@pytest.mark.parametrize("file_name, object_type", happy_cases) +def test_happy_cases(file_name, object_type): + file = Path(__file__).parent.resolve() / "test_data" / file_name + # Put JSON in default unindented format + with open(file, "r") as f: + data = json.load(f) + json_string = json.dumps(data, separators=(",", ":"), ensure_ascii=False) + + # Round-trip + assert object_type.model_validate_json(json_string).model_dump_json(exclude_none=True) == json_string + + +error_cases = [("label-or-symbol-unit.json", Unit, r"Either 'label' or 'symbol' should be set")] + + +@pytest.mark.parametrize("file_name, object_type, error_message", error_cases) +def test_error_cases(file_name, object_type, error_message): + file = Path(__file__).parent.resolve() / "test_data" / file_name + # Put JSON in default unindented format + with open(file, "r") as f: + data = json.load(f) + json_string = json.dumps(data, separators=(",", ":")) + + with pytest.raises(ValidationError, match=error_message): + object_type.model_validate_json(json_string)