├── tests ├── __init__.py ├── test_data │ ├── str-axes.json │ ├── mixed-type-axes-2.json │ ├── mixed-type-axes.json │ ├── temporalrs-no-calendar.json │ ├── mixed-type-ndarray-1.json │ ├── mixed-type-ndarray-2.json │ ├── mixed-type-ndarray-3.json │ ├── ndarray-integer.json │ ├── ndarray-float.json │ ├── spec-ndarray.json │ ├── ndarray-string.json │ ├── spec-parametergroup.json │ ├── spec-domain-point.json │ ├── spec-domain-point-compact.json │ ├── spec-domain-point-series.json │ ├── spec-domain-vertical-profile.json │ ├── spec-domain-grid.json │ ├── point-series-domain-no-t.json │ ├── spec-domain-trajectory.json │ ├── spec-domain-multipoint.json │ ├── continuous-data-parameter.json │ ├── spec-domain-multipoint-series.json │ ├── spec-reference-system-identifierrs.json │ ├── spec-tiled-ndarray.json │ ├── spec-axes.json │ ├── parameters.json │ ├── example_py.json │ ├── grid-domain-no-y.json │ ├── point-series-domain-custom.json │ ├── categorical-data-parameter.json │ ├── point-series-domain-more-z.json │ ├── grid-domain.json │ ├── spec-domain-polygon-series.json │ ├── doc-example-coverage.json │ ├── spec-trajectory-coverage.json │ ├── coverage-json.json │ ├── coverage-mixed-type-ndarray.json │ ├── polygon-series-coverage-collection.json │ ├── doc-example-coverage-collection.json │ └── spec-vertical-profile-coverage.json └── test_coverage.py ├── src └── covjson_pydantic │ ├── __init__.py │ ├── py.typed │ ├── base_models.py │ ├── observed_property.py │ ├── i18n.py │ ├── unit.py │ ├── coverage.py │ ├── parameter.py │ ├── reference_system.py │ ├── ndarray.py │ └── domain.py ├── setup.py ├── .gitignore ├── performance.py ├── example.py ├── pyproject.toml ├── .github └── workflows │ └── ci.yml ├── .pre-commit-config.yaml ├── README.md └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/covjson_pydantic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/covjson_pydantic/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_data/str-axes.json: -------------------------------------------------------------------------------- 1 | { 2 | "x": { 3 | "values": [ 4 | "foo", 5 | "bar" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/test_data/mixed-type-axes-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "x": { 3 | "values": [ 4 | "foo", 5 | 42.0 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/test_data/mixed-type-axes.json: -------------------------------------------------------------------------------- 1 | { 2 | "x": { 3 | "values": [ 4 | 42.0, 5 | "foo" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/test_data/temporalrs-no-calendar.json: -------------------------------------------------------------------------------- 1 | { 2 | "coordinates": [ 3 | "t" 4 | ], 5 | "system": { 6 | "type": "TemporalRS", 7 | "id": "http://www.opengis.net/def/crs/OGC/1.3/" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import setuptools 5 | 6 | logging.basicConfig() 7 | logger = logging.getLogger(__name__) 8 | logger.setLevel(os.environ.get("LOG_LEVEL", "INFO")) 9 | 10 | if __name__ == "__main__": 11 | setuptools.setup() 12 | -------------------------------------------------------------------------------- /tests/test_data/mixed-type-ndarray-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "NdArray", 3 | "dataType": "float", 4 | "axisNames": [ 5 | "y", 6 | "x" 7 | ], 8 | "shape": [ 9 | 2 10 | ], 11 | "values": [ 12 | "42.0", 13 | 123 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tests/test_data/mixed-type-ndarray-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "NdArray", 3 | "dataType": "float", 4 | "axisNames": [ 5 | "y", 6 | "x" 7 | ], 8 | "shape": [ 9 | 2 10 | ], 11 | "values": [ 12 | "foo", 13 | "bar" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tests/test_data/mixed-type-ndarray-3.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "NdArray", 3 | "dataType": "integer", 4 | "axisNames": [ 5 | "y", 6 | "x" 7 | ], 8 | "shape": [ 9 | 2 10 | ], 11 | "values": [ 12 | 1, 13 | 1.42 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tests/test_data/ndarray-integer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "NdArray", 3 | "dataType": "integer", 4 | "axisNames": [ 5 | "t", 6 | "y", 7 | "x" 8 | ], 9 | "shape": [ 10 | 1, 11 | 1, 12 | 3 13 | ], 14 | "values": [ 15 | 1, 16 | 2, 17 | 42 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Code Editors 2 | ## Jet Brains 3 | .idea/ 4 | .run/ 5 | __pycache__/ 6 | 7 | # Python 8 | ## MyPy 9 | .mypy_cache 10 | ## Virtual Environment 11 | venv/ 12 | 13 | # unit test results 14 | .coverage 15 | .pytest_cache 16 | TEST-*-*.xml 17 | coverage.json 18 | coverage.xml 19 | htmlcov/ 20 | junit-report.xml 21 | 22 | # Ignore package 23 | *.egg-info/ 24 | .env 25 | build/ 26 | dist/ 27 | -------------------------------------------------------------------------------- /tests/test_data/ndarray-float.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "NdArray", 3 | "dataType": "float", 4 | "axisNames": [ 5 | "t", 6 | "y", 7 | "x" 8 | ], 9 | "shape": [ 10 | 1, 11 | 2, 12 | 3 13 | ], 14 | "values": [ 15 | 27.1, 16 | 24.1, 17 | null, 18 | 25.1, 19 | 26.7, 20 | 23.2 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tests/test_data/spec-ndarray.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "NdArray", 3 | "dataType": "float", 4 | "axisNames": [ 5 | "y", 6 | "x" 7 | ], 8 | "shape": [ 9 | 4, 10 | 2 11 | ], 12 | "values": [ 13 | 12.3, 14 | 12.5, 15 | 11.5, 16 | 23.1, 17 | null, 18 | null, 19 | 10.1, 20 | 9.1 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tests/test_data/ndarray-string.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "NdArray", 3 | "dataType": "string", 4 | "axisNames": [ 5 | "t", 6 | "y", 7 | "x" 8 | ], 9 | "shape": [ 10 | 1, 11 | 2, 12 | 3 13 | ], 14 | "values": [ 15 | "ABC", 16 | "DEF", 17 | null, 18 | "XYZ", 19 | "a123", 20 | "qwerty" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/covjson_pydantic/base_models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel as PydanticBaseModel 2 | from pydantic import ConfigDict 3 | 4 | 5 | class CovJsonBaseModel(PydanticBaseModel): 6 | model_config = ConfigDict( 7 | str_strip_whitespace=True, 8 | str_min_length=1, 9 | extra="forbid", 10 | validate_default=True, 11 | validate_assignment=True, 12 | strict=True, 13 | ) 14 | -------------------------------------------------------------------------------- /tests/test_data/spec-parametergroup.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "ParameterGroup", 3 | "label": { 4 | "en": "Daily sea surface temperature with uncertainty information" 5 | }, 6 | "observedProperty": { 7 | "id": "http://vocab.nerc.ac.uk/standard_name/sea_surface_temperature/", 8 | "label": { 9 | "en": "Sea surface temperature" 10 | } 11 | }, 12 | "members": [ 13 | "SST_mean", 14 | "SST_stddev" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/covjson_pydantic/observed_property.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from typing import Optional 3 | 4 | from .base_models import CovJsonBaseModel 5 | from .i18n import i18n 6 | 7 | 8 | class Category(CovJsonBaseModel): 9 | id: str 10 | label: i18n 11 | description: Optional[i18n] = None 12 | 13 | 14 | class ObservedProperty(CovJsonBaseModel): 15 | id: Optional[str] = None 16 | label: i18n 17 | description: Optional[i18n] = None 18 | categories: Optional[List[Category]] = None 19 | -------------------------------------------------------------------------------- /tests/test_data/spec-domain-point.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "Point", 4 | "axes": { 5 | "x": { 6 | "values": [ 7 | 1.0 8 | ] 9 | }, 10 | "y": { 11 | "values": [ 12 | 20.0 13 | ] 14 | }, 15 | "z": { 16 | "values": [ 17 | 1.8 18 | ] 19 | }, 20 | "t": { 21 | "values": [ 22 | "2008-01-01T04:00:00Z" 23 | ] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/test_data/spec-domain-point-compact.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "Point", 4 | "axes": { 5 | "x": { 6 | "start": 1.0, 7 | "stop": 1.0, 8 | "num": 1 9 | }, 10 | "y": { 11 | "start": 20.0, 12 | "stop": 20.0, 13 | "num": 1 14 | }, 15 | "z": { 16 | "start": 1.8, 17 | "stop": 1.8, 18 | "num": 1 19 | }, 20 | "t": { 21 | "values": [ 22 | "2008-01-01T04:00:00Z" 23 | ] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/test_data/spec-domain-point-series.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "PointSeries", 4 | "axes": { 5 | "x": { 6 | "values": [ 7 | 1.0 8 | ] 9 | }, 10 | "y": { 11 | "values": [ 12 | 20.0 13 | ] 14 | }, 15 | "z": { 16 | "values": [ 17 | 1.0 18 | ] 19 | }, 20 | "t": { 21 | "values": [ 22 | "2008-01-01T04:00:00Z", 23 | "2008-01-01T05:00:00Z" 24 | ] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/test_data/spec-domain-vertical-profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "VerticalProfile", 4 | "axes": { 5 | "x": { 6 | "values": [ 7 | 1.0 8 | ] 9 | }, 10 | "y": { 11 | "values": [ 12 | 21.0 13 | ] 14 | }, 15 | "z": { 16 | "values": [ 17 | 1.0, 18 | 5.0, 19 | 20.0 20 | ] 21 | }, 22 | "t": { 23 | "values": [ 24 | "2008-01-01T04:00:00Z" 25 | ] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/test_data/spec-domain-grid.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "Grid", 4 | "axes": { 5 | "x": { 6 | "values": [ 7 | 1.0, 8 | 2.0, 9 | 3.0 10 | ] 11 | }, 12 | "y": { 13 | "values": [ 14 | 20.0, 15 | 21.0 16 | ] 17 | }, 18 | "z": { 19 | "values": [ 20 | 1.0 21 | ] 22 | }, 23 | "t": { 24 | "values": [ 25 | "2008-01-01T04:00:00Z" 26 | ] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/covjson_pydantic/i18n.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Dict 3 | 4 | 5 | class LanguageTag(str, Enum): 6 | dutch = "nl" 7 | english = "en" 8 | german = "de" 9 | undefined = "und" 10 | 11 | 12 | # TODO: This was throwing warning: 13 | # Expected `definition-ref` but got `LanguageTag` - serialized value may not be as expected 14 | # This may be a bug in Pydantic: https://github.com/pydantic/pydantic/issues/6467 15 | # or: https://github.com/pydantic/pydantic/issues/6422 16 | # So, for now, reverted to a less strict type 17 | # See issue: https://github.com/KNMI/covjson-pydantic/issues/3 18 | # i18n = Dict[LanguageTag, str] 19 | i18n = Dict[str, str] 20 | -------------------------------------------------------------------------------- /tests/test_data/point-series-domain-no-t.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "PointSeries", 4 | "axes": { 5 | "x": { 6 | "values": [ 7 | 5.3 8 | ] 9 | }, 10 | "y": { 11 | "values": [ 12 | 53.2 13 | ] 14 | } 15 | }, 16 | "referencing": [ 17 | { 18 | "coordinates": [ 19 | "y", 20 | "x" 21 | ], 22 | "system": { 23 | "type": "GeographicCRS", 24 | "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /tests/test_data/spec-domain-trajectory.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "Trajectory", 4 | "axes": { 5 | "composite": { 6 | "dataType": "tuple", 7 | "coordinates": [ 8 | "t", 9 | "x", 10 | "y" 11 | ], 12 | "values": [ 13 | [ 14 | "2008-01-01T04:00:00Z", 15 | 1, 16 | 20 17 | ], 18 | [ 19 | "2008-01-01T04:30:00Z", 20 | 2, 21 | 21 22 | ] 23 | ] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/covjson_pydantic/unit.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from typing import Union 3 | 4 | from pydantic import model_validator 5 | 6 | from .base_models import CovJsonBaseModel 7 | from .i18n import i18n 8 | 9 | 10 | class Symbol(CovJsonBaseModel): 11 | value: str 12 | type: str 13 | 14 | 15 | class Unit(CovJsonBaseModel): 16 | id: Optional[str] = None 17 | label: Optional[i18n] = None 18 | symbol: Optional[Union[str, Symbol]] = None 19 | 20 | @model_validator(mode="after") 21 | def check_either_label_or_symbol(self): 22 | if self.label is None and self.symbol is None: 23 | raise ValueError("Either 'label' or 'symbol' should be set") 24 | 25 | return self 26 | -------------------------------------------------------------------------------- /tests/test_data/spec-domain-multipoint.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "MultiPoint", 4 | "axes": { 5 | "t": { 6 | "values": [ 7 | "2008-01-01T04:00:00Z" 8 | ] 9 | }, 10 | "composite": { 11 | "dataType": "tuple", 12 | "coordinates": [ 13 | "x", 14 | "y", 15 | "z" 16 | ], 17 | "values": [ 18 | [ 19 | 1.0, 20 | 20.0, 21 | 1.0 22 | ], 23 | [ 24 | 2.0, 25 | 21.0, 26 | 3.0 27 | ] 28 | ] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/test_data/continuous-data-parameter.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Parameter", 3 | "description": { 4 | "en": "The sea surface temperature in degrees Celsius." 5 | }, 6 | "observedProperty": { 7 | "id": "http://vocab.nerc.ac.uk/standard_name/sea_surface_temperature/", 8 | "label": { 9 | "en": "Sea Surface Temperature" 10 | }, 11 | "description": { 12 | "en": "The temperature of sea water near the surface (including the part under sea-ice, if any), and not the skin temperature." 13 | } 14 | }, 15 | "unit": { 16 | "label": { 17 | "en": "Degree Celsius" 18 | }, 19 | "symbol": { 20 | "value": "Cel", 21 | "type": "http://www.opengis.net/def/uom/UCUM/" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/test_data/spec-domain-multipoint-series.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "MultiPointSeries", 4 | "axes": { 5 | "t": { 6 | "values": [ 7 | "2008-01-01T04:00:00Z", 8 | "2008-01-01T05:00:00Z" 9 | ] 10 | }, 11 | "composite": { 12 | "dataType": "tuple", 13 | "coordinates": [ 14 | "x", 15 | "y", 16 | "z" 17 | ], 18 | "values": [ 19 | [ 20 | 1, 21 | 20, 22 | 1 23 | ], 24 | [ 25 | 2, 26 | 21, 27 | 3 28 | ] 29 | ] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /performance.py: -------------------------------------------------------------------------------- 1 | import timeit 2 | from pathlib import Path 3 | 4 | filename = Path(__file__).parent.resolve() / "tests" / "test_data" / "coverage-json.json" 5 | 6 | setup = f""" 7 | import json 8 | from covjson_pydantic.coverage import Coverage 9 | 10 | file = "{filename}" 11 | # Put JSON in default unindented format 12 | with open(file, "r") as f: 13 | data = json.load(f) 14 | json_string = json.dumps(data, separators=(",", ":")) 15 | cj = Coverage.model_validate_json(json_string) 16 | """ 17 | 18 | # This can be used to quickly check performance. The first call checks JSON to Python conversion 19 | # The second call checks Python to JSON conversion 20 | # Consider generating a larger CoverageJSON file 21 | print(timeit.timeit("Coverage.model_validate_json(json_string)", setup, number=1000)) 22 | print(timeit.timeit("cj.model_dump_json(exclude_none=True)", setup, number=1000)) 23 | -------------------------------------------------------------------------------- /tests/test_data/spec-reference-system-identifierrs.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "IdentifierRS", 3 | "id": "https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2", 4 | "label": { 5 | "en": "ISO 3166-1 alpha-2 codes" 6 | }, 7 | "targetConcept": { 8 | "id": "http://dbpedia.org/resource/Country", 9 | "label": { 10 | "en": "Country", 11 | "de": "Land" 12 | } 13 | }, 14 | "identifiers": { 15 | "de": { 16 | "id": "http://dbpedia.org/resource/Germany", 17 | "label": { 18 | "de": "Deutschland", 19 | "en": "Germany" 20 | } 21 | }, 22 | "gb": { 23 | "id": "http://dbpedia.org/resource/United_Kingdom", 24 | "label": { 25 | "de": "Vereinigtes Konigreich", 26 | "en": "United Kingdom" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from datetime import timezone 3 | 4 | from covjson_pydantic.coverage import Coverage 5 | from covjson_pydantic.domain import Axes 6 | from covjson_pydantic.domain import Domain 7 | from covjson_pydantic.domain import DomainType 8 | from covjson_pydantic.domain import ValuesAxis 9 | from covjson_pydantic.ndarray import NdArrayFloat 10 | from pydantic import AwareDatetime 11 | 12 | c = Coverage( 13 | domain=Domain( 14 | domainType=DomainType.point_series, 15 | axes=Axes( 16 | x=ValuesAxis[float](values=[1.23]), 17 | y=ValuesAxis[float](values=[4.56]), 18 | t=ValuesAxis[AwareDatetime](values=[datetime(2024, 8, 1, tzinfo=timezone.utc)]), 19 | ), 20 | ), 21 | ranges={"temperature": NdArrayFloat(axisNames=["x", "y", "t"], shape=[1, 1, 1], values=[42.0])}, 22 | ) 23 | 24 | print(c.model_dump_json(exclude_none=True, indent=4)) 25 | -------------------------------------------------------------------------------- /tests/test_data/spec-tiled-ndarray.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "TiledNdArray", 3 | "dataType": "float", 4 | "axisNames": [ 5 | "t", 6 | "y", 7 | "x" 8 | ], 9 | "shape": [ 10 | 2, 11 | 5, 12 | 10 13 | ], 14 | "tileSets": [ 15 | { 16 | "tileShape": [ 17 | null, 18 | null, 19 | null 20 | ], 21 | "urlTemplate": "http://example.com/a/all.covjson" 22 | }, 23 | { 24 | "tileShape": [ 25 | 1, 26 | null, 27 | null 28 | ], 29 | "urlTemplate": "http://example.com/b/{t}.covjson" 30 | }, 31 | { 32 | "tileShape": [ 33 | null, 34 | 2, 35 | 3 36 | ], 37 | "urlTemplate": "http://example.com/c/{y}-{x}.covjson" 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /tests/test_data/spec-axes.json: -------------------------------------------------------------------------------- 1 | { 2 | "x": { 3 | "values": [ 4 | 20.0, 5 | 21.0 6 | ], 7 | "bounds": [ 8 | 19.5, 9 | 20.5, 10 | 20.5, 11 | 21.5 12 | ] 13 | }, 14 | "y": { 15 | "start": 0.0, 16 | "stop": 5.0, 17 | "num": 6 18 | }, 19 | "t": { 20 | "values": [ 21 | "2008-01-01T04:00:00Z", 22 | "2008-01-02T04:00:00Z" 23 | ] 24 | }, 25 | "composite": { 26 | "dataType": "tuple", 27 | "coordinates": [ 28 | "t", 29 | "x", 30 | "y" 31 | ], 32 | "values": [ 33 | [ 34 | "2008-01-01T04:00:00Z", 35 | 1, 36 | 20 37 | ], 38 | [ 39 | "2008-01-01T04:30:00Z", 40 | 2, 41 | 21 42 | ] 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/test_data/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "PSAL": { 3 | "type": "Parameter", 4 | "description": { 5 | "en": "The measured salinity, in practical salinity units (psu) of the sea water" 6 | }, 7 | "observedProperty": { 8 | "id": "https://vocab.nerc.ac.uk/standard_name/sea_water_salinity/", 9 | "label": { 10 | "en": "Sea Water Salinity" 11 | } 12 | }, 13 | "unit": { 14 | "symbol": "psu" 15 | } 16 | }, 17 | "POTM": { 18 | "type": "Parameter", 19 | "description": { 20 | "en": "The potential temperature, in degrees Celsius, of the sea water" 21 | }, 22 | "observedProperty": { 23 | "id": "https://vocab.nerc.ac.uk/standard_name/sea_water_potential_temperature/", 24 | "label": { 25 | "en": "Sea Water Potential Temperature" 26 | } 27 | }, 28 | "unit": { 29 | "symbol": "°C" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/test_data/example_py.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Coverage", 3 | "domain": { 4 | "type": "Domain", 5 | "domainType": "PointSeries", 6 | "axes": { 7 | "x": { 8 | "values": [ 9 | 1.23 10 | ] 11 | }, 12 | "y": { 13 | "values": [ 14 | 4.56 15 | ] 16 | }, 17 | "t": { 18 | "values": [ 19 | "2024-08-01T00:00:00Z" 20 | ] 21 | } 22 | } 23 | }, 24 | "ranges": { 25 | "temperature": { 26 | "type": "NdArray", 27 | "dataType": "float", 28 | "axisNames": [ 29 | "x", 30 | "y", 31 | "t" 32 | ], 33 | "shape": [ 34 | 1, 35 | 1, 36 | 1 37 | ], 38 | "values": [ 39 | 42.0 40 | ] 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/test_data/grid-domain-no-y.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "Grid", 4 | "axes": { 5 | "x": { 6 | "values": [ 7 | 1.0, 8 | 2.0, 9 | 3.0 10 | ] 11 | }, 12 | "z": { 13 | "values": [ 14 | 1.0 15 | ] 16 | }, 17 | "t": { 18 | "values": [ 19 | "2008-01-01T04:00:00Z" 20 | ] 21 | } 22 | }, 23 | "referencing": [ 24 | { 25 | "coordinates": [ 26 | "t" 27 | ], 28 | "system": { 29 | "type": "TemporalRS", 30 | "calendar": "Gregorian" 31 | } 32 | }, 33 | { 34 | "coordinates": [ 35 | "x", 36 | "z" 37 | ], 38 | "system": { 39 | "type": "GeographicCRS", 40 | "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" 41 | } 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /tests/test_data/point-series-domain-custom.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "PointSeries", 4 | "axes": { 5 | "x": { 6 | "values": [ 7 | 5.3 8 | ] 9 | }, 10 | "y": { 11 | "values": [ 12 | 53.2 13 | ] 14 | }, 15 | "t": { 16 | "dataType": "knmi:range", 17 | "values": [ 18 | "2022-01-01T04:03:00Z", 19 | "2022-01-01T05:09:00Z" 20 | ], 21 | "knmi:num": 10 22 | } 23 | }, 24 | "referencing": [ 25 | { 26 | "coordinates": [ 27 | "t" 28 | ], 29 | "system": { 30 | "type": "TemporalRS", 31 | "calendar": "Gregorian" 32 | } 33 | }, 34 | { 35 | "coordinates": [ 36 | "y", 37 | "x" 38 | ], 39 | "system": { 40 | "type": "GeographicCRS", 41 | "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" 42 | } 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /tests/test_data/categorical-data-parameter.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Parameter", 3 | "description": { 4 | "en": "The land cover category." 5 | }, 6 | "observedProperty": { 7 | "id": "http://example.com/land_cover", 8 | "label": { 9 | "en": "Land Cover" 10 | }, 11 | "description": { 12 | "en": "longer description..." 13 | }, 14 | "categories": [ 15 | { 16 | "id": "http://example.com/land_cover/categories/grass", 17 | "label": { 18 | "en": "Grass" 19 | }, 20 | "description": { 21 | "en": "Very green grass." 22 | } 23 | }, 24 | { 25 | "id": "http://example.com/land_cover/categories/forest", 26 | "label": { 27 | "en": "Forest" 28 | } 29 | } 30 | ] 31 | }, 32 | "categoryEncoding": { 33 | "http://example.com/land_cover/categories/grass": 1, 34 | "http://example.com/land_cover/categories/forest": [ 35 | 2, 36 | 3 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/test_data/point-series-domain-more-z.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "PointSeries", 4 | "axes": { 5 | "x": { 6 | "values": [ 7 | 5.3 8 | ] 9 | }, 10 | "y": { 11 | "values": [ 12 | 53.2 13 | ] 14 | }, 15 | "z": { 16 | "values": [ 17 | 53.2, 18 | 54.2 19 | ] 20 | }, 21 | "t": { 22 | "values": [ 23 | "2022-01-01T04:03:00Z", 24 | "2022-01-01T05:09:00Z" 25 | ] 26 | } 27 | }, 28 | "referencing": [ 29 | { 30 | "coordinates": [ 31 | "t" 32 | ], 33 | "system": { 34 | "type": "TemporalRS", 35 | "calendar": "Gregorian" 36 | } 37 | }, 38 | { 39 | "coordinates": [ 40 | "y", 41 | "x", 42 | "z" 43 | ], 44 | "system": { 45 | "type": "GeographicCRS", 46 | "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" 47 | } 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /tests/test_data/grid-domain.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "Grid", 4 | "axes": { 5 | "x": { 6 | "values": [ 7 | 1.0, 8 | 2.0, 9 | 3.0 10 | ] 11 | }, 12 | "y": { 13 | "values": [ 14 | 20.0, 15 | 21.0 16 | ] 17 | }, 18 | "z": { 19 | "values": [ 20 | 1.0 21 | ] 22 | }, 23 | "t": { 24 | "values": [ 25 | "2008-01-01T04:00:00Z", 26 | "2008-01-01T05:00:00Z", 27 | "2008-01-01T06:00:00Z" 28 | ] 29 | } 30 | }, 31 | "referencing": [ 32 | { 33 | "coordinates": [ 34 | "t" 35 | ], 36 | "system": { 37 | "type": "TemporalRS", 38 | "calendar": "Gregorian" 39 | } 40 | }, 41 | { 42 | "coordinates": [ 43 | "y", 44 | "x", 45 | "z" 46 | ], 47 | "system": { 48 | "type": "GeographicCRS", 49 | "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" 50 | } 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /tests/test_data/spec-domain-polygon-series.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Domain", 3 | "domainType": "PolygonSeries", 4 | "axes": { 5 | "z": { 6 | "values": [ 7 | 2.0 8 | ] 9 | }, 10 | "t": { 11 | "values": [ 12 | "2008-01-01T04:00:00Z", 13 | "2008-01-01T05:00:00Z" 14 | ] 15 | }, 16 | "composite": { 17 | "dataType": "polygon", 18 | "coordinates": [ 19 | "x", 20 | "y" 21 | ], 22 | "values": [ 23 | [ 24 | [ 25 | [ 26 | 100.0, 27 | 0.0 28 | ], 29 | [ 30 | 101.0, 31 | 0.0 32 | ], 33 | [ 34 | 101.0, 35 | 1.0 36 | ], 37 | [ 38 | 100.0, 39 | 1.0 40 | ], 41 | [ 42 | 100.0, 43 | 0.0 44 | ] 45 | ] 46 | ] 47 | ] 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "covjson-pydantic" 3 | description = "The Pydantic models for CoverageJSON" 4 | readme = "README.md" 5 | requires-python = ">=3.8" 6 | license = {file = "LICENSE"} 7 | authors = [ 8 | {name = "KNMI Data Platform Team", email = "opendata@knmi.nl"}, 9 | ] 10 | keywords = ["covjson", "Pydantic"] 11 | classifiers = [ 12 | "Intended Audience :: Information Technology", 13 | "Intended Audience :: Science/Research", 14 | "License :: OSI Approved :: Apache Software License", 15 | "Programming Language :: Python :: 3.8", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "Topic :: Scientific/Engineering :: GIS", 22 | "Typing :: Typed", 23 | ] 24 | version = "0.7.0" 25 | dependencies = ["pydantic>=2.3,<3"] 26 | 27 | [project.optional-dependencies] 28 | test = ["pytest", "pytest-cov"] 29 | dev = ["pre-commit"] 30 | 31 | [project.urls] 32 | Source = "https://github.com/knmi/covjson-pydantic" 33 | 34 | [build-system] 35 | requires = ["flit>=3.2,<4"] 36 | build-backend = "flit_core.buildapi" 37 | 38 | [tool.flit.module] 39 | name = "covjson_pydantic" 40 | 41 | [tool.flit.sdist] 42 | exclude = [ 43 | "test/", 44 | ".github/", 45 | ] 46 | 47 | [tool.mypy] 48 | plugins = [ 49 | "pydantic.mypy" 50 | ] 51 | 52 | [tool.pydantic-mypy] 53 | warn_untyped_fields = true 54 | -------------------------------------------------------------------------------- /src/covjson_pydantic/coverage.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info < (3, 9): 4 | from typing_extensions import Annotated 5 | else: 6 | from typing import Annotated 7 | 8 | from typing import Dict 9 | from typing import List 10 | from typing import Literal 11 | from typing import Optional 12 | from typing import Union 13 | 14 | from pydantic import AnyUrl 15 | from pydantic import Field 16 | 17 | from .base_models import CovJsonBaseModel 18 | from .domain import Domain 19 | from .domain import DomainType 20 | from .ndarray import NdArrayFloat 21 | from .ndarray import NdArrayInt 22 | from .ndarray import NdArrayStr 23 | from .ndarray import TiledNdArrayFloat 24 | from .parameter import Parameters 25 | from .parameter import ParameterGroup 26 | from .reference_system import ReferenceSystemConnectionObject 27 | 28 | NdArrayTypes = Annotated[Union[NdArrayFloat, NdArrayInt, NdArrayStr], Field(discriminator="dataType")] 29 | 30 | 31 | class Coverage(CovJsonBaseModel, extra="allow"): 32 | id: Optional[str] = None 33 | type: Literal["Coverage"] = "Coverage" 34 | domain: Domain 35 | parameters: Optional[Parameters] = None 36 | parameterGroups: Optional[List[ParameterGroup]] = None # noqa: N815 37 | ranges: Dict[str, Union[NdArrayTypes, TiledNdArrayFloat, AnyUrl]] 38 | 39 | 40 | class CoverageCollection(CovJsonBaseModel, extra="allow"): 41 | type: Literal["CoverageCollection"] = "CoverageCollection" 42 | domainType: Optional[DomainType] = None # noqa: N815 43 | coverages: List[Coverage] 44 | parameters: Optional[Parameters] = None 45 | parameterGroups: Optional[List[ParameterGroup]] = None # noqa: N815 46 | referencing: Optional[List[ReferenceSystemConnectionObject]] = None 47 | -------------------------------------------------------------------------------- /tests/test_data/doc-example-coverage.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Coverage", 3 | "domain": { 4 | "type": "Domain", 5 | "domainType": "Grid", 6 | "axes": { 7 | "x": { 8 | "start": -179.5, 9 | "stop": 179.5, 10 | "num": 360 11 | }, 12 | "y": { 13 | "start": -89.5, 14 | "stop": 89.5, 15 | "num": 180 16 | }, 17 | "t": { 18 | "values": [ 19 | "2013-01-13T00:00:00Z" 20 | ] 21 | } 22 | }, 23 | "referencing": [ 24 | { 25 | "coordinates": [ 26 | "x", 27 | "y" 28 | ], 29 | "system": { 30 | "type": "GeographicCRS", 31 | "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" 32 | } 33 | }, 34 | { 35 | "coordinates": [ 36 | "t" 37 | ], 38 | "system": { 39 | "type": "TemporalRS", 40 | "calendar": "Gregorian" 41 | } 42 | } 43 | ] 44 | }, 45 | "parameters": { 46 | "TEMP": { 47 | "type": "Parameter", 48 | "description": { 49 | "en": "The air temperature measured in degrees Celsius." 50 | }, 51 | "observedProperty": { 52 | "id": "http://vocab.nerc.ac.uk/standard_name/air_temperature/", 53 | "label": { 54 | "en": "Air temperature", 55 | "de": "Lufttemperatur" 56 | } 57 | }, 58 | "unit": { 59 | "label": { 60 | "en": "Degree Celsius" 61 | }, 62 | "symbol": { 63 | "value": "Cel", 64 | "type": "http://www.opengis.net/def/uom/UCUM/" 65 | } 66 | } 67 | } 68 | }, 69 | "ranges": { 70 | "TEMP": "http://example.com/coverages/123/TEMP" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/covjson_pydantic/parameter.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from typing import List 3 | from typing import Literal 4 | from typing import Optional 5 | from typing import Union 6 | 7 | from pydantic import model_validator 8 | from pydantic import RootModel 9 | 10 | from .base_models import CovJsonBaseModel 11 | from .i18n import i18n 12 | from .observed_property import ObservedProperty 13 | from .unit import Unit 14 | 15 | 16 | class Parameter(CovJsonBaseModel, extra="allow"): 17 | type: Literal["Parameter"] = "Parameter" 18 | id: Optional[str] = None 19 | label: Optional[i18n] = None 20 | description: Optional[i18n] = None 21 | observedProperty: ObservedProperty # noqa: N815 22 | categoryEncoding: Optional[Dict[str, Union[int, List[int]]]] = None # noqa: N815 23 | unit: Optional[Unit] = None 24 | 25 | @model_validator(mode="after") 26 | def must_not_have_unit_if_observed_property_has_categories(self): 27 | if self.unit is not None and self.observedProperty is not None and self.observedProperty.categories is not None: 28 | raise ValueError( 29 | "A parameter object MUST NOT have a 'unit' member " 30 | "if the 'observedProperty' member has a 'categories' member." 31 | ) 32 | 33 | return self 34 | 35 | 36 | class Parameters(RootModel): 37 | root: Dict[str, Parameter] 38 | 39 | def __iter__(self): 40 | return iter(self.root) 41 | 42 | def __getitem__(self, key): 43 | return self.root[key] 44 | 45 | def get(self, key, default=None): 46 | return self.root.get(key, default) 47 | 48 | 49 | class ParameterGroup(CovJsonBaseModel, extra="allow"): 50 | type: Literal["ParameterGroup"] = "ParameterGroup" 51 | id: Optional[str] = None 52 | label: Optional[i18n] = None 53 | description: Optional[i18n] = None 54 | observedProperty: Optional[ObservedProperty] = None # noqa: N815 55 | members: List[str] 56 | 57 | @model_validator(mode="after") 58 | def must_have_label_and_or_observed_property(self): 59 | if self.label is None and self.observedProperty is None: 60 | raise ValueError( 61 | "A parameter group object MUST have either or both the members 'label' or/and 'observedProperty'" 62 | ) 63 | return self 64 | -------------------------------------------------------------------------------- /src/covjson_pydantic/reference_system.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from typing import List 3 | from typing import Literal 4 | from typing import Optional 5 | from typing import Union 6 | 7 | from pydantic import AnyUrl 8 | from pydantic import model_validator 9 | 10 | from .base_models import CovJsonBaseModel 11 | from .i18n import i18n 12 | 13 | 14 | class TargetConcept(CovJsonBaseModel): 15 | id: Optional[str] = None # Not in spec, but needed for example in spec for 'Identifier-based Reference Systems' 16 | label: i18n 17 | description: Optional[i18n] = None 18 | 19 | 20 | class ReferenceSystem(CovJsonBaseModel, extra="allow"): 21 | type: Literal["GeographicCRS", "ProjectedCRS", "VerticalCRS", "TemporalRS", "IdentifierRS"] 22 | id: Optional[str] = None 23 | description: Optional[i18n] = None 24 | 25 | # Only for TemporalRS 26 | calendar: Optional[Union[Literal["Gregorian"], AnyUrl]] = None 27 | timeScale: Optional[AnyUrl] = None # noqa: N815 28 | 29 | # Only for IdentifierRS 30 | label: Optional[i18n] = None 31 | targetConcept: Optional[TargetConcept] = None # noqa: N815 32 | identifiers: Optional[Dict[str, TargetConcept]] = None 33 | 34 | @model_validator(mode="after") 35 | def check_type_specific_fields(self): 36 | if self.type != "TemporalRS" and (self.calendar is not None or self.timeScale is not None): 37 | raise ValueError("'calendar' and 'timeScale' fields can only be used for type 'TemporalRS'") 38 | 39 | if self.type == "TemporalRS" and self.calendar is None: 40 | raise ValueError("A temporal RS object MUST have a member 'calendar' with value 'Gregorian' or a URI") 41 | 42 | if self.type != "IdentifierRS" and ( 43 | self.label is not None or self.targetConcept is not None or self.identifiers is not None 44 | ): 45 | raise ValueError( 46 | "'label', 'targetConcept' and 'identifiers' fields can only be used for type 'IdentifierRS'" 47 | ) 48 | 49 | if self.type == "IdentifierRS" and self.targetConcept is None: 50 | raise ValueError("An identifier RS object MUST have a member 'targetConcept'") 51 | 52 | return self 53 | 54 | 55 | class ReferenceSystemConnectionObject(CovJsonBaseModel): 56 | coordinates: List[str] 57 | system: ReferenceSystem 58 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | pull_request: 10 | env: 11 | LATEST_PY_VERSION: '3.10' 12 | 13 | jobs: 14 | tests: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install .["test"] 31 | 32 | - name: Run pre-commit 33 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} 34 | run: | 35 | python -m pip install pre-commit 36 | pre-commit run --all-files 37 | 38 | - name: Run tests 39 | run: python -m pytest --cov covjson_pydantic --cov-report xml --cov-report term-missing 40 | 41 | - name: Upload Results 42 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} 43 | uses: codecov/codecov-action@v4 44 | env: 45 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 46 | with: 47 | file: ./coverage.xml 48 | flags: unittests 49 | name: ${{ matrix.python-version }} 50 | fail_ci_if_error: false 51 | 52 | publish: 53 | needs: [tests] 54 | runs-on: ubuntu-latest 55 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Set up Python 59 | uses: actions/setup-python@v5 60 | with: 61 | python-version: ${{ env.LATEST_PY_VERSION }} 62 | 63 | - name: Install dependencies 64 | run: | 65 | python -m pip install --upgrade pip 66 | python -m pip install flit 67 | python -m pip install . 68 | 69 | - name: Set tag version 70 | id: tag 71 | run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT 72 | 73 | - name: Set module version 74 | id: module 75 | run: echo "version=$(python -c 'from importlib.metadata import version; print(version("covjson_pydantic"))')" >> $GITHUB_OUTPUT 76 | 77 | - name: Build and publish 78 | if: steps.tag.outputs.tag == steps.module.outputs.version 79 | env: 80 | FLIT_USERNAME: __token__ 81 | FLIT_PASSWORD: ${{ secrets.PYPI_TOKEN }} 82 | run: flit publish 83 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.0.1 4 | hooks: 5 | # Formatting 6 | - id: end-of-file-fixer # Makes sure files end in a newline and only a newline. 7 | - id: pretty-format-json 8 | args: [ 9 | '--autofix', 10 | '--indent=4', 11 | '--no-ensure-ascii', 12 | '--no-sort-keys' 13 | ] # Formats and sorts your JSON files. 14 | - id: trailing-whitespace # Trims trailing whitespace. 15 | # Checks 16 | - id: check-json # Attempts to load all json files to verify syntax. 17 | - id: check-merge-conflict # Check for files that contain merge conflict strings. 18 | - id: check-shebang-scripts-are-executable # Checks that scripts with shebangs are executable. 19 | - id: check-yaml 20 | # only checks syntax not load the yaml: 21 | # https://stackoverflow.com/questions/59413979/how-exclude-ref-tag-from-check-yaml-git-hook 22 | args: [ '--unsafe' ] # Parse the yaml files for syntax. 23 | 24 | # reorder-python-imports ~ sort python imports 25 | - repo: https://github.com/asottile/reorder_python_imports 26 | rev: v2.6.0 27 | hooks: 28 | - id: reorder-python-imports 29 | 30 | # black ~ Formats Python code 31 | - repo: https://github.com/psf/black 32 | rev: 22.3.0 33 | hooks: 34 | - id: black 35 | args: [ 36 | '--line-length=120' 37 | ] 38 | 39 | # flake8 ~ Enforces the Python PEP8 style guide 40 | # Configure the pep8-naming flake plugin to recognise @classmethod, @validator, @root_validator as classmethod. 41 | # Ignore the unused imports (F401) for the __init__ files, the imports are not always used inside the file, 42 | # but used to setup how other files can import it in a more convenient way. 43 | - repo: https://github.com/pycqa/flake8 44 | rev: 4.0.1 45 | hooks: 46 | - id: flake8 47 | args: [ 48 | '--classmethod-decorators=classmethod,validator,root_validator', 49 | '--ignore=E203,W503', 50 | '--max-line-length=120', 51 | '--per-file-ignores=__init__.py:F401' 52 | ] 53 | additional_dependencies: [ 'pep8-naming==0.12.1' ] 54 | 55 | - repo: https://github.com/pre-commit/mirrors-mypy 56 | rev: v1.5.1 57 | hooks: 58 | - id: mypy 59 | language_version: python 60 | # No reason to run if only tests have changed. They intentionally break typing. 61 | exclude: tests/.* 62 | # Pass mypy the entire folder because a change in one file can break others. 63 | args: [--config-file=pyproject.toml, src/] 64 | # Don't pass it the individual filenames because it is already doing the whole folder. 65 | pass_filenames: false 66 | additional_dependencies: 67 | - pydantic 68 | -------------------------------------------------------------------------------- /src/covjson_pydantic/ndarray.py: -------------------------------------------------------------------------------- 1 | import math 2 | from typing import List 3 | from typing import Literal 4 | from typing import Optional 5 | 6 | from pydantic import model_validator 7 | 8 | from .base_models import CovJsonBaseModel 9 | 10 | 11 | class NdArray(CovJsonBaseModel, extra="allow"): 12 | type: Literal["NdArray"] = "NdArray" 13 | dataType: str # Kept here to ensure order of output in JSON # noqa: N815 14 | axisNames: Optional[List[str]] = None # noqa: N815 15 | shape: Optional[List[int]] = None 16 | 17 | @model_validator(mode="before") 18 | @classmethod 19 | def validate_is_sub_class(cls, values): 20 | if cls is NdArray: 21 | raise TypeError( 22 | "NdArray cannot be instantiated directly, please use a NdArrayFloat, NdArrayInt or NdArrayStr" 23 | ) 24 | return values 25 | 26 | @model_validator(mode="after") 27 | def check_field_dependencies(self): 28 | if len(self.values) > 1 and (self.axisNames is None or len(self.axisNames) == 0): 29 | raise ValueError("'axisNames' must to be provided if array is not 0D") 30 | 31 | if len(self.values) > 1 and (self.shape is None or len(self.shape) == 0): 32 | raise ValueError("'shape' must to be provided if array is not 0D") 33 | 34 | if self.axisNames is not None and self.shape is not None and len(self.axisNames) != len(self.shape): 35 | raise ValueError("'axisNames' and 'shape' should have equal length") 36 | 37 | if self.shape is not None and len(self.shape) >= 1: 38 | prod = math.prod(self.shape) 39 | if len(self.values) != prod: 40 | raise ValueError( 41 | "Where 'shape' is present and non-empty, the product of its values MUST equal " 42 | "the number of elements in the 'values' array." 43 | ) 44 | 45 | return self 46 | 47 | 48 | class NdArrayFloat(NdArray): 49 | dataType: Literal["float"] = "float" # noqa: N815 50 | values: List[Optional[float]] 51 | 52 | 53 | class NdArrayInt(NdArray): 54 | dataType: Literal["integer"] = "integer" # noqa: N815 55 | values: List[Optional[int]] 56 | 57 | 58 | class NdArrayStr(NdArray): 59 | dataType: Literal["string"] = "string" # noqa: N815 60 | values: List[Optional[str]] 61 | 62 | 63 | class TileSet(CovJsonBaseModel): 64 | tileShape: List[Optional[int]] # noqa: N815 65 | urlTemplate: str # noqa: N815 66 | 67 | 68 | # TODO: Validation of field dependencies 69 | # TODO: Support string and integer type TiledNdArray 70 | class TiledNdArrayFloat(CovJsonBaseModel, extra="allow"): 71 | type: Literal["TiledNdArray"] = "TiledNdArray" 72 | dataType: Literal["float"] = "float" # noqa: N815 73 | axisNames: List[str] # noqa: N815 74 | shape: List[int] 75 | tileSets: List[TileSet] # noqa: N815 76 | -------------------------------------------------------------------------------- /tests/test_data/spec-trajectory-coverage.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Coverage", 3 | "domain": { 4 | "type": "Domain", 5 | "domainType": "Trajectory", 6 | "axes": { 7 | "composite": { 8 | "dataType": "tuple", 9 | "coordinates": [ 10 | "t", 11 | "x", 12 | "y", 13 | "z" 14 | ], 15 | "values": [ 16 | [ 17 | "2008-01-01T04:00:00Z", 18 | 1, 19 | 20, 20 | 1 21 | ], 22 | [ 23 | "2008-01-01T04:30:00Z", 24 | 2, 25 | 21, 26 | 3 27 | ] 28 | ] 29 | } 30 | }, 31 | "referencing": [ 32 | { 33 | "coordinates": [ 34 | "x", 35 | "y" 36 | ], 37 | "system": { 38 | "type": "GeographicCRS", 39 | "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" 40 | } 41 | }, 42 | { 43 | "coordinates": [ 44 | "z" 45 | ], 46 | "system": { 47 | "type": "VerticalCRS", 48 | "cs": { 49 | "csAxes": [ 50 | { 51 | "name": { 52 | "en": "Pressure" 53 | }, 54 | "direction": "down", 55 | "unit": { 56 | "symbol": "Pa" 57 | } 58 | } 59 | ] 60 | } 61 | } 62 | }, 63 | { 64 | "coordinates": [ 65 | "t" 66 | ], 67 | "system": { 68 | "type": "TemporalRS", 69 | "calendar": "Gregorian" 70 | } 71 | } 72 | ] 73 | }, 74 | "parameters": { 75 | "temperature": { 76 | "type": "Parameter", 77 | "description": { 78 | "en": "This is the air temperature" 79 | }, 80 | "observedProperty": { 81 | "label": { 82 | "en": "temperature" 83 | } 84 | }, 85 | "unit": { 86 | "label": { 87 | "en": "Degree Celsius" 88 | }, 89 | "symbol": { 90 | "value": "Cel", 91 | "type": "http://www.opengis.net/def/uom/UCUM" 92 | } 93 | } 94 | } 95 | }, 96 | "ranges": { 97 | "temperature": { 98 | "type": "NdArray", 99 | "dataType": "float", 100 | "axisNames": [ 101 | "composite" 102 | ], 103 | "shape": [ 104 | 2 105 | ], 106 | "values": [ 107 | 10.1, 108 | 11.3 109 | ] 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/test_data/coverage-json.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Coverage", 3 | "domain": { 4 | "type": "Domain", 5 | "domainType": "PointSeries", 6 | "axes": { 7 | "x": { 8 | "values": [ 9 | 5.3 10 | ] 11 | }, 12 | "y": { 13 | "values": [ 14 | 53.2 15 | ] 16 | }, 17 | "t": { 18 | "values": [ 19 | "2022-01-01T04:10:00Z", 20 | "2022-01-01T04:20:00Z", 21 | "2022-01-01T04:30:00Z", 22 | "2022-01-01T04:40:00Z", 23 | "2022-01-01T04:50:00Z", 24 | "2022-01-01T05:00:00Z" 25 | ] 26 | } 27 | } 28 | }, 29 | "parameters": { 30 | "temperature": { 31 | "type": "Parameter", 32 | "description": { 33 | "en": "This is the air temperature" 34 | }, 35 | "observedProperty": { 36 | "label": { 37 | "en": "temperature" 38 | } 39 | }, 40 | "unit": { 41 | "label": { 42 | "en": "Degree Celsius" 43 | }, 44 | "symbol": { 45 | "value": "Cel", 46 | "type": "http://www.opengis.net/def/uom/UCUM" 47 | } 48 | } 49 | }, 50 | "dewpoint": { 51 | "type": "Parameter", 52 | "description": { 53 | "en": "This is the air dewpoint" 54 | }, 55 | "observedProperty": { 56 | "label": { 57 | "en": "dewpoint" 58 | } 59 | }, 60 | "unit": { 61 | "label": { 62 | "en": "Degree Celsius" 63 | }, 64 | "symbol": { 65 | "value": "Cel", 66 | "type": "http://www.opengis.net/def/uom/UCUM" 67 | } 68 | } 69 | } 70 | }, 71 | "ranges": { 72 | "temperature": { 73 | "type": "NdArray", 74 | "dataType": "float", 75 | "axisNames": [ 76 | "x", 77 | "y", 78 | "t" 79 | ], 80 | "shape": [ 81 | 1, 82 | 1, 83 | 6 84 | ], 85 | "values": [ 86 | 64.27437704538298, 87 | 64.70702358086481, 88 | 65.13680141101983, 89 | 65.56289242989419, 90 | 65.98448554858814, 91 | 66.40077824091921 92 | ] 93 | }, 94 | "dewpoint": { 95 | "type": "NdArray", 96 | "dataType": "float", 97 | "axisNames": [ 98 | "x", 99 | "y", 100 | "t" 101 | ], 102 | "shape": [ 103 | 1, 104 | 1, 105 | 6 106 | ], 107 | "values": [ 108 | 62.27437704538298, 109 | 62.707023580864806, 110 | 63.136801411019825, 111 | 63.56289242989419, 112 | 63.98448554858814, 113 | 64.40077824091921 114 | ] 115 | } 116 | }, 117 | "extra:extra": "extra fields allowed" 118 | } 119 | -------------------------------------------------------------------------------- /tests/test_data/coverage-mixed-type-ndarray.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Coverage", 3 | "domain": { 4 | "type": "Domain", 5 | "domainType": "PointSeries", 6 | "axes": { 7 | "x": { 8 | "values": [ 9 | 5.3 10 | ] 11 | }, 12 | "y": { 13 | "values": [ 14 | 53.2 15 | ] 16 | }, 17 | "t": { 18 | "values": [ 19 | "2022-01-01T04:10:00Z", 20 | "2022-01-01T04:20:00Z", 21 | "2022-01-01T04:30:00Z" 22 | ] 23 | } 24 | } 25 | }, 26 | "parameters": { 27 | "float-parameter": { 28 | "type": "Parameter", 29 | "observedProperty": { 30 | "label": { 31 | "en": "float" 32 | } 33 | } 34 | }, 35 | "string-parameter": { 36 | "type": "Parameter", 37 | "observedProperty": { 38 | "label": { 39 | "en": "string" 40 | } 41 | } 42 | }, 43 | "integer-parameter": { 44 | "type": "Parameter", 45 | "observedProperty": { 46 | "label": { 47 | "en": "integer" 48 | } 49 | } 50 | }, 51 | "null-parameter": { 52 | "type": "Parameter", 53 | "observedProperty": { 54 | "label": { 55 | "en": "null" 56 | } 57 | } 58 | } 59 | }, 60 | "ranges": { 61 | "string-parameter": { 62 | "type": "NdArray", 63 | "dataType": "string", 64 | "axisNames": [ 65 | "x", 66 | "y", 67 | "t" 68 | ], 69 | "shape": [ 70 | 1, 71 | 1, 72 | 3 73 | ], 74 | "values": [ 75 | null, 76 | "foo", 77 | "bar" 78 | ] 79 | }, 80 | "float-parameter": { 81 | "type": "NdArray", 82 | "dataType": "float", 83 | "axisNames": [ 84 | "x", 85 | "y", 86 | "t" 87 | ], 88 | "shape": [ 89 | 1, 90 | 1, 91 | 3 92 | ], 93 | "values": [ 94 | 62.0, 95 | null, 96 | 63.136801411019825 97 | ] 98 | }, 99 | "integer-parameter": { 100 | "type": "NdArray", 101 | "dataType": "integer", 102 | "axisNames": [ 103 | "x", 104 | "y", 105 | "t" 106 | ], 107 | "shape": [ 108 | 1, 109 | 1, 110 | 3 111 | ], 112 | "values": [ 113 | 1, 114 | null, 115 | 3 116 | ] 117 | }, 118 | "null-parameter": { 119 | "type": "NdArray", 120 | "dataType": "integer", 121 | "axisNames": [ 122 | "x", 123 | "y", 124 | "t" 125 | ], 126 | "shape": [ 127 | 1, 128 | 1, 129 | 3 130 | ], 131 | "values": [ 132 | null, 133 | null, 134 | null 135 | ] 136 | } 137 | }, 138 | "extra:extra": "extra fields allowed" 139 | } 140 | -------------------------------------------------------------------------------- /tests/test_data/polygon-series-coverage-collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "CoverageCollection", 3 | "coverages": [ 4 | { 5 | "type": "Coverage", 6 | "domain": { 7 | "type": "Domain", 8 | "domainType": "PolygonSeries", 9 | "axes": { 10 | "t": { 11 | "values": [ 12 | "2016-01-01T00:00:00Z", 13 | "2016-02-01T00:00:00Z", 14 | "2016-03-01T00:00:00Z" 15 | ] 16 | }, 17 | "composite": { 18 | "dataType": "polygon", 19 | "coordinates": [ 20 | "x", 21 | "y" 22 | ], 23 | "values": [ 24 | [ 25 | [ 26 | [ 27 | -105.67217, 28 | 36.02425 29 | ], 30 | [ 31 | -105.88091, 32 | 35.24744 33 | ], 34 | [ 35 | -105.01299, 36 | 32.0286 37 | ], 38 | [ 39 | -103.54082, 40 | 32.07516 41 | ], 42 | [ 43 | -104.06816, 44 | 34.75247 45 | ], 46 | [ 47 | -105.67217, 48 | 36.02425 49 | ] 50 | ] 51 | ] 52 | ] 53 | } 54 | } 55 | }, 56 | "ranges": { 57 | "Lake/Reservoir Storage End of Month": { 58 | "type": "NdArray", 59 | "dataType": "float", 60 | "axisNames": [ 61 | "t" 62 | ], 63 | "shape": [ 64 | 3 65 | ], 66 | "values": [ 67 | 1.0, 68 | 2.0, 69 | 42.0 70 | ] 71 | } 72 | } 73 | } 74 | ], 75 | "parameters": { 76 | "Lake/Reservoir Storage End of Month": { 77 | "type": "Parameter", 78 | "description": { 79 | "en": "Instant daily lake/reservoir storage volume in acre-feet. Monthly refers to one measurement on the last day of each month." 80 | }, 81 | "observedProperty": { 82 | "id": "1470", 83 | "label": { 84 | "en": "Lake/Reservoir Storage End of Month" 85 | } 86 | }, 87 | "unit": { 88 | "symbol": "af" 89 | } 90 | } 91 | }, 92 | "referencing": [ 93 | { 94 | "coordinates": [ 95 | "x", 96 | "y" 97 | ], 98 | "system": { 99 | "type": "GeographicCRS", 100 | "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" 101 | } 102 | }, 103 | { 104 | "coordinates": [ 105 | "z" 106 | ], 107 | "system": { 108 | "type": "VerticalCRS", 109 | "cs": { 110 | "csAxes": [ 111 | { 112 | "name": { 113 | "en": "time" 114 | }, 115 | "direction": "down", 116 | "unit": { 117 | "symbol": "time" 118 | } 119 | } 120 | ] 121 | } 122 | } 123 | }, 124 | { 125 | "coordinates": [ 126 | "t" 127 | ], 128 | "system": { 129 | "type": "TemporalRS", 130 | "calendar": "Gregorian" 131 | } 132 | } 133 | ] 134 | } 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CoverageJSON Pydantic 2 | 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |