diff --git a/.gitignore b/.gitignore index 4b381b7..deabeb3 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,6 @@ target/ # Pycopy __pycache__/ + +# macOS +.DS_Store diff --git a/create_clc_collection.py b/create_clc_collection.py new file mode 100644 index 0000000..60e594b --- /dev/null +++ b/create_clc_collection.py @@ -0,0 +1,15 @@ +import logging + +from scripts.clc.collection import create_collection, populate_collection + +LOGGER = logging.getLogger(__name__) + + +def main(): + logging.basicConfig(filename="create_clc_collection.log") + collection = create_collection() + populate_collection(collection, data_root="../CLC_100m") + + +if __name__ == "__main__": + main() diff --git a/create_euhydro_collection.py b/create_euhydro_collection.py new file mode 100644 index 0000000..adacd87 --- /dev/null +++ b/create_euhydro_collection.py @@ -0,0 +1,9 @@ +import logging + +from scripts.euhydro.collection import create_euhydro_collection + +LOGGER = logging.getLogger(__name__) + +if __name__ == "__main__": + logging.basicConfig(filename="create_euhydro_collection.log") + create_euhydro_collection("") diff --git a/create_n2k_collection.py b/create_n2k_collection.py new file mode 100644 index 0000000..7a74691 --- /dev/null +++ b/create_n2k_collection.py @@ -0,0 +1,9 @@ +import logging + +from scripts.n2k.collection import create_n2k_collection + +LOGGER = logging.getLogger(__name__) + +if __name__ == "__main__": + logging.basicConfig(filename="create_n2k_collection.log") + create_n2k_collection("") diff --git a/create_uabh_collection.py b/create_uabh_collection.py new file mode 100644 index 0000000..18bc67d --- /dev/null +++ b/create_uabh_collection.py @@ -0,0 +1,18 @@ +import logging +from glob import glob + +from scripts.uabh.collection import create_uabh_collection, get_stac_validator +from scripts.uabh.constants import COLLECTION_ID, STAC_DIR, WORKING_DIR + +LOGGER = logging.getLogger(__name__) + + +def main(): + logging.basicConfig(filename="create_uabh_collection.log") + item_list = glob(f"{WORKING_DIR}/{STAC_DIR}/{COLLECTION_ID}/**/*.json") + validator = get_stac_validator("schema/products/uabh.json") + create_uabh_collection(item_list, validator) + + +if __name__ == "__main__": + main() diff --git a/create_uabh_items.py b/create_uabh_items.py new file mode 100644 index 0000000..619c533 --- /dev/null +++ b/create_uabh_items.py @@ -0,0 +1,18 @@ +import logging +from glob import glob + +from scripts.uabh.item import create_uabh_item, get_stac_validator + +LOGGER = logging.getLogger(__name__) + + +def main(): + logging.basicConfig(filename="create_uabh_items.log") + validator = get_stac_validator("schema/products/uabh.json") + zip_list = glob("/Users/chung-xianghong/Downloads/uabh_samples/**/*.zip") + for zip_file in zip_list: + create_uabh_item(zip_file, validator) + + +if __name__ == "__main__": + main() diff --git a/create_vpp_collection.py b/create_vpp_collection.py new file mode 100644 index 0000000..9b8cd74 --- /dev/null +++ b/create_vpp_collection.py @@ -0,0 +1,18 @@ +import logging +from glob import glob + +from scripts.vpp.collection import create_vpp_collection, get_stac_validator +from scripts.vpp.constants import COLLECTION_ID, STAC_DIR, WORKING_DIR + +LOGGER = logging.getLogger(__name__) + + +def main(): + logging.basicConfig(filename="create_vpp_collection.log") + item_list = glob(f"{WORKING_DIR}/{STAC_DIR}/{COLLECTION_ID}/**/*.json") + validator = get_stac_validator("schema/products/vpp.json") + create_vpp_collection(item_list, validator) + + +if __name__ == "__main__": + main() diff --git a/create_vpp_items.py b/create_vpp_items.py new file mode 100644 index 0000000..19bcb4a --- /dev/null +++ b/create_vpp_items.py @@ -0,0 +1,34 @@ +import itertools as it +import logging +from concurrent.futures import ThreadPoolExecutor + +from tqdm import tqdm + +from scripts.vpp.constants import AWS_SESSION, BUCKET +from scripts.vpp.item import create_page_iterator, create_product_list, create_vpp_item, get_stac_validator + +LOGGER = logging.getLogger(__name__) + + +def main(): + logging.basicConfig(filename="create_vpp_items.log") + validator = get_stac_validator("schema/products/vpp.json") + product_list = create_product_list(2017, 2023) + + for product in product_list: + page_iterator = create_page_iterator(AWS_SESSION, BUCKET, product) + for page in page_iterator: + tiles = [prefix["Prefix"] for prefix in page["CommonPrefixes"]] + with ThreadPoolExecutor(max_workers=100) as executor: + list( + tqdm( + executor.map( + create_vpp_item, it.repeat(AWS_SESSION), it.repeat(BUCKET), it.repeat(validator), tiles + ), + total=len(tiles), + ) + ) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index ceaa59e..a38f271 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ #Libraries that your project use +boto3 +pyproj pystac pystac[validation] +rasterio +shapely diff --git a/schema/products/eu-hydro.json b/schema/products/eu-hydro.json index 9954bbe..afe0cb2 100644 --- a/schema/products/eu-hydro.json +++ b/schema/products/eu-hydro.json @@ -62,7 +62,7 @@ "name": "Copernicus Land Monitoring Service", "description": "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of users in Europe and across the World in the field of environmental terrestrial applications.", "roles": ["licensor", "host"], - "url": "https://land.copernicus.eu/en" + "url": "https://land.copernicus.eu" } ] }, @@ -79,9 +79,7 @@ }, "temporal": { "const": { - "interval": [ - ["2006-01-01T00:00:00.000Z", "2012-12-31T00:00:00.000Z"] - ] + "interval": [["2006-01-01T00:00:00Z", "2012-12-31T00:00:00Z"]] } } } @@ -151,7 +149,10 @@ }, "assets": { "type": "object", - "required": ["eu-hydro_v1p3_user_guide", "how_use_esri_fgdb_in_qgis"], + "required": [ + "EU-HYDRO_V1p3_User_Guide_pdf", + "How_use_ESRI_FGDB_in_QGIS_pdf" + ], "additionalProperties": { "type": "object", "properties": { diff --git a/schema/products/n2k.json b/schema/products/n2k.json index 1663b8f..a8442ab 100644 --- a/schema/products/n2k.json +++ b/schema/products/n2k.json @@ -54,7 +54,7 @@ "name": "Copernicus Land Monitoring Service", "description": "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of users in Europe and across the World in the field of environmental terrestrial applications.", "roles": ["licensor", "host"], - "url": "https://land.copernicus.eu/en" + "url": "https://land.copernicus.eu" } ] }, @@ -71,7 +71,7 @@ ] }, "temporal": { - "const": { "interval": [["2006-01-01T00:00:00.000Z", null]] } + "const": { "interval": [["2006-01-01T00:00:00Z", null]] } } } }, @@ -141,7 +141,7 @@ "assets": { "type": "object", "propertyNames": { - "pattern": "gdb$|gpkg$|zip$|metadata$|arcgis_layer$|qgis_layer$|ogc_layer$" + "pattern": "gdb$|gpkg$|zip$|xml$|lyr$|qml$|sld$" } } } diff --git a/schema/products/uabh.json b/schema/products/uabh.json index 616973a..4ac3fea 100644 --- a/schema/products/uabh.json +++ b/schema/products/uabh.json @@ -142,17 +142,8 @@ }, "assets": { "type": "object", - "minProperties": 4, - "maxProperties": 6, "propertyNames": { - "anyOf": [ - { "const": "dataset" }, - { "const": "quality_check_report" }, - { "const": "metadata" }, - { "const": "quality_control_report" }, - { "type": "string", "pattern": "^pixel_based_info" }, - { "const": "compressed_dataset" } - ] + "pattern": "^(A[LT]|B[AEG]|C[HYZ]|D[EK]|E[ELS]|F[IR]|H[RU]|I[EST]|L[TUV]|M[EKT]|N[LO]|P[LT]|R[OS]|S[EIK]|TR|UK|XK)[0-9]{3}" } } } @@ -179,8 +170,8 @@ "stac_version": { "const": "1.0.0" }, "stac_extensions": { "const": [ - "https://stac-extensions.github.io/item-assets/v1.0.0/schema.json", - "https://stac-extensions.github.io/projection/v1.1.0/schema.json" + "https://stac-extensions.github.io/projection/v1.1.0/schema.json", + "https://stac-extensions.github.io/item-assets/v1.0.0/schema.json" ] }, "type": { "const": "Collection" }, @@ -201,7 +192,7 @@ "name": "Copernicus Land Monitoring Service", "description": "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of users in Europe and across the World in the field of environmental terrestrial applications.", "roles": ["licensor", "host"], - "url": "https://land.copernicus.eu/en" + "url": "https://land.copernicus.eu" } ] }, @@ -211,7 +202,7 @@ "bbox": [[-22.13, 35.07, 33.48, 64.38]] }, "temporal": { - "interval": [["2012-01-01T00:00:00.000Z", null]] + "interval": [["2012-01-01T00:00:00Z", null]] } } }, diff --git a/schema/products/vpp.json b/schema/products/vpp.json index 88ee16c..e309b6a 100644 --- a/schema/products/vpp.json +++ b/schema/products/vpp.json @@ -37,50 +37,50 @@ "oneOf": [ { "items": [ - { "type": "number", "minimum": -25, "maximum": 45 }, - { "type": "number", "minimum": 26, "maximum": 72 }, - { "type": "number", "minimum": -25, "maximum": 45 }, - { "type": "number", "minimum": 26, "maximum": 72 } + { "type": "number", "minimum": -31.86, "maximum": 45.12 }, + { "type": "number", "minimum": 26.99, "maximum": 72.09 }, + { "type": "number", "minimum": -31.86, "maximum": 45.12 }, + { "type": "number", "minimum": 26.99, "maximum": 72.09 } ] }, { "items": [ - { "type": "number", "minimum": -55.23, "maximum": -50.89 }, - { "type": "number", "minimum": 1.69, "maximum": 6.34 }, - { "type": "number", "minimum": -55.23, "maximum": -50.89 }, - { "type": "number", "minimum": 1.69, "maximum": 6.34 } + { "type": "number", "minimum": -62.09, "maximum": -60.1 }, + { "type": "number", "minimum": 13.45, "maximum": 15.38 }, + { "type": "number", "minimum": -62.09, "maximum": -60.1 }, + { "type": "number", "minimum": 13.45, "maximum": 15.38 } ] }, { "items": [ - { "type": "number", "minimum": -62.09, "maximum": -60.08 }, - { "type": "number", "minimum": 15.26, "maximum": 17.19 }, - { "type": "number", "minimum": -62.09, "maximum": -60.08 }, - { "type": "number", "minimum": 15.26, "maximum": 17.19 } + { "type": "number", "minimum": 44.07, "maximum": 46.02 }, + { "type": "number", "minimum": -13.67, "maximum": -11.75 }, + { "type": "number", "minimum": 44.07, "maximum": 46.02 }, + { "type": "number", "minimum": -13.67, "maximum": -11.75 } ] }, { "items": [ - { "type": "number", "minimum": -62.1, "maximum": -60.1 }, - { "type": "number", "minimum": 13.45, "maximum": 15.38 }, - { "type": "number", "minimum": -62.1, "maximum": -60.1 }, - { "type": "number", "minimum": 13.45, "maximum": 15.38 } + { "type": "number", "minimum": 55.06, "maximum": 56.15 }, + { "type": "number", "minimum": -21.8, "maximum": -19.88 }, + { "type": "number", "minimum": 55.06, "maximum": 56.15 }, + { "type": "number", "minimum": -21.8, "maximum": -19.88 } ] }, { "items": [ - { "type": "number", "minimum": 44.05, "maximum": 46.04 }, - { "type": "number", "minimum": -13.68, "maximum": -11.73 }, - { "type": "number", "minimum": 44.05, "maximum": 46.04 }, - { "type": "number", "minimum": -13.68, "maximum": -11.73 } + { "type": "number", "minimum": -62.08, "maximum": -60.08 }, + { "type": "number", "minimum": 15.26, "maximum": 17.19 }, + { "type": "number", "minimum": -62.08, "maximum": -60.08 }, + { "type": "number", "minimum": 15.26, "maximum": 17.19 } ] }, { "items": [ - { "type": "number", "minimum": 55.04, "maximum": 56.16 }, - { "type": "number", "minimum": -21.81, "maximum": -19.87 }, - { "type": "number", "minimum": 55.04, "maximum": 56.16 }, - { "type": "number", "minimum": -21.81, "maximum": -19.87 } + { "type": "number", "minimum": -55.21, "maximum": -50.9 }, + { "type": "number", "minimum": 1.71, "maximum": 6.34 }, + { "type": "number", "minimum": -55.21, "maximum": -50.9 }, + { "type": "number", "minimum": 1.71, "maximum": 6.34 } ] } ] @@ -120,6 +120,12 @@ "description": "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of users in Europe and across the World in the field of environmental terrestrial applications.", "roles": ["licensor", "host"], "url": "https://land.copernicus.eu" + }, + { + "name": "VITO NV", + "description": "VITO is an independent Flemish research organisation in the area of cleantech and sustainable development.", + "roles": ["processor", "producer"], + "url": "https://vito.be" } ] }, @@ -221,8 +227,8 @@ "stac_version": { "const": "1.0.0" }, "stac_extensions": { "const": [ - "https://stac-extensions.github.io/item-assets/v1.0.0/schema.json", - "https://stac-extensions.github.io/projection/v1.1.0/schema.json" + "https://stac-extensions.github.io/projection/v1.1.0/schema.json", + "https://stac-extensions.github.io/item-assets/v1.0.0/schema.json" ] }, "type": { "const": "Collection" }, @@ -251,7 +257,13 @@ "name": "Copernicus Land Monitoring Service", "description": "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of users in Europe and across the World in the field of environmental terrestrial applications.", "roles": ["licensor", "host"], - "url": "https://land.copernicus.eu/en" + "url": "https://land.copernicus.eu" + }, + { + "name": "VITO NV", + "description": "VITO is an independent Flemish research organisation in the area of cleantech and sustainable development.", + "roles": ["processor", "producer"], + "url": "https://vito.be" } ] }, @@ -261,7 +273,7 @@ "bbox": [[-25, 26, 45, 72]] }, "temporal": { - "interval": [["2017-01-01T00:00:00.000Z", null]] + "interval": [["2017-01-01T00:00:00Z", null]] } } }, diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/clc/__init__.py b/scripts/clc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/clc/collection.py b/scripts/clc/collection.py new file mode 100644 index 0000000..4b456b7 --- /dev/null +++ b/scripts/clc/collection.py @@ -0,0 +1,177 @@ +import json +import logging +import os +from datetime import UTC, datetime + +import pystac +import pystac.item +import pystac.link + +# Taken 'as is' from other scripts +from jsonschema import Draft7Validator +from jsonschema.exceptions import best_match +from pystac.extensions.item_assets import AssetDefinition, ItemAssetsExtension +from pystac.extensions.projection import ProjectionExtension +from referencing import Registry, Resource + +from .constants import ( + CLMS_LICENSE, + COLLECTION_DESCRIPTION, + COLLECTION_ID, + COLLECTION_KEYWORDS, + COLLECTION_LICENSE, + COLLECTION_MEDIA_TYPE_MAP, + COLLECTION_ROLES_MAP, + COLLECTION_TITLE, + COLLECTION_TITLE_MAP, + ITEM_MEDIA_TYPE_MAP, + ITEM_ROLES_MAP, + ITEM_TITLE_MAP, + STAC_DIR, + WORKING_DIR, +) +from .item import create_item, deconstruct_clc_name, get_img_paths + +LOGGER = logging.getLogger(__name__) + + +# Taken 'as is' from other scripts +def get_stac_validator(product_schema: str) -> Draft7Validator: + with open(product_schema, encoding="utf-8") as f: + schema = json.load(f) + registry = Registry().with_resources( + [("http://example.com/schema.json", Resource.from_contents(schema))], + ) + return Draft7Validator({"$ref": "http://example.com/schema.json"}, registry=registry) + + +def proj_epsg_from_item_asset(item: pystac.Item) -> int: + for asset_key in item.assets: + asset = item.assets[asset_key].to_dict() + if "proj:epsg" in asset: + return asset.get("proj:epsg") + + return None + + +def get_collection_asset_files(data_root: str) -> list[str]: + asset_files = [] + + for root, _, files in os.walk(data_root): + for file in files: + if ( + (file.startswith("clc-country-coverage") and file.endswith("pdf")) + or file.startswith("clc-file-naming-convention") + or (file.startswith("readme") and file.endswith("raster.txt")) + ): + asset_files.append(os.path.join(root, file)) + + return asset_files + + +def create_collection_asset(asset_file: str) -> tuple[str, pystac.Asset]: + filename_elements = deconstruct_clc_name(asset_file) + clc_id = filename_elements["id"] + + if clc_id.startswith("clc-file-naming"): + key = "clc_file_naming" + elif clc_id.startswith("clc-country-coverage"): + key = "clc_country_coverage" + elif clc_id.startswith("readme"): + key = "readme" + + asset = pystac.Asset( + href=asset_file, + title=COLLECTION_TITLE_MAP[key], + media_type=COLLECTION_MEDIA_TYPE_MAP[key], + roles=COLLECTION_ROLES_MAP[key], + ) + + return clc_id, asset + + +def create_collection() -> pystac.Collection: + sp_extent = pystac.SpatialExtent([None, None, None, None]) + tmp_extent = pystac.TemporalExtent([datetime(1990, 1, 1, microsecond=0, tzinfo=UTC), None]) + extent = pystac.Extent(sp_extent, tmp_extent) + + collection = pystac.Collection( + id=COLLECTION_ID, + description=COLLECTION_DESCRIPTION, + title=COLLECTION_TITLE, + extent=extent, + keywords=COLLECTION_KEYWORDS, + license=COLLECTION_LICENSE, + stac_extensions=[], + ) + + item_assets = ItemAssetsExtension.ext(collection, add_if_missing=True) + item_assets.item_assets = { + f"clc_map_{key}": AssetDefinition( + { + "title": ITEM_TITLE_MAP[key].format(label="").strip(), + "media_type": ITEM_MEDIA_TYPE_MAP[key], + "roles": ITEM_ROLES_MAP[key], + } + ) + for key in ITEM_TITLE_MAP + } + + collection.add_link(CLMS_LICENSE) + collection.set_self_href(os.path.join(WORKING_DIR, f"{STAC_DIR}/{collection.id}/{collection.id}.json")) + catalog = pystac.read_file(f"{WORKING_DIR}/{STAC_DIR}/clms_catalog.json") + + collection.set_root(catalog) + collection.set_parent(catalog) + + collection.save_object() + + return collection + + +def populate_collection(collection: pystac.Collection, data_root: str) -> pystac.Collection: + img_paths = get_img_paths(data_root) + + proj_epsg = [] + for img_path in img_paths: + item = create_item(img_path, data_root) + collection.add_item(item) + + item_epsg = proj_epsg_from_item_asset(item) + proj_epsg.append(item_epsg) + + dom_code = deconstruct_clc_name(img_path).get("DOM_code") + href = os.path.join( + WORKING_DIR, f"{STAC_DIR}/{COLLECTION_ID}/{item.id.removesuffix(f'_FR_{dom_code}')}/{item.id}.json" + ) + item.set_self_href(href) + + validator = get_stac_validator("schema/products/clc.json") + error_msg = best_match(validator.iter_errors(item.to_dict())) + try: + assert error_msg is None, f"Failed to create {item.id} item. Reason: {error_msg}." + except AssertionError as error: + LOGGER.error(error) + + item.save_object() + + asset_files = get_collection_asset_files(data_root) + + for asset_file in asset_files: + key, asset = create_collection_asset(asset_file) + collection.assets |= {key: asset} + + collection.make_all_asset_hrefs_relative() + collection.update_extent_from_items() + ProjectionExtension.add_to(collection) + collection.summaries = pystac.Summaries({"proj:epsg": list(set(proj_epsg))}) + + try: + error_msg = best_match(validator.iter_errors(collection.to_dict())) + assert error_msg is None, f"Failed to create {collection.id} collection. Reason: {error_msg}." + except AssertionError as error: + LOGGER.error(error) + + collection.save_object() + + return collection diff --git a/scripts/clc/constants.py b/scripts/clc/constants.py new file mode 100644 index 0000000..d309219 --- /dev/null +++ b/scripts/clc/constants.py @@ -0,0 +1,148 @@ +import os + +import pystac +from pystac.provider import ProviderRole + +# os.chdir('x:\\projects\\ETC-DI\\Task_18\\clms-stac') +WORKING_DIR = os.getcwd() + +STAC_DIR = "stac_tests" + +# Collection +COLLECTION_ID = "corine-land-cover-raster" +COLLECTION_TITLE = "CORINE Land Cover Raster" +COLLECTION_DESCRIPTION = ( + "The European Commission launched the CORINE (Coordination of Information on the Environment) " + "program in an effort to develop a standardized methodology for producing continent-scale land " + "cover, biotope, and air quality maps. The CORINE Land Cover (CLC) product offers a pan-European " + "land cover and land use inventory with 44 thematic classes, ranging from broad forested areas " + "to individual vineyards." +) +COLLECTION_KEYWORDS = ["clms", "corine", "derived data", "land cover", "machine learning", "open data"] +COLLECTION_LICENSE = "proprietary" + + +COLLECTION_TITLE_MAP = { + "clc_country_coverage": "Coverage", + "clc_file_naming": "Naming Convention Description", + "readme": "Description", +} + +COLLECTION_MEDIA_TYPE_MAP = { + "clc_country_coverage": pystac.MediaType.PDF, + "clc_file_naming": pystac.MediaType.TEXT, + "readme": pystac.MediaType.TEXT, +} + +COLLECTION_ROLES_MAP = { + "clc_country_coverage": ["metadata"], + "clc_file_naming": ["metadata"], + "readme": ["metadata"], +} + +# Items + +CLMS_LICENSE = pystac.link.Link( + rel=pystac.RelType.LICENSE, + target="https://land.copernicus.eu/en/data-policy", + title="Legal notice on the use of CLMS data", +) + +DOM_MAP = { + "GLP": "Guadeloupe", + "GUF": "French Guyana", + "MTQ": "Martinique", + "MYT": "Mayotte", + "REU": "Réunion", + "": "Europe", +} + +ITEM_MEDIA_TYPE_MAP = { + "tif": pystac.MediaType.COG, + "tif_xml": pystac.MediaType.XML, + "tif_aux_xml": pystac.MediaType.XML, + "tif_ovr": "image/tiff; application=geotiff; profile=pyramid", + "tif_vat_cpg": pystac.MediaType.TEXT, + "tif_vat_dbf": "application/dbf", + "legend_txt": pystac.MediaType.TEXT, + "tif_lyr": "image/tiff; application=geotiff; profile=layer", + "tfw": pystac.MediaType.TEXT, + "xml": pystac.MediaType.XML, + "readme_txt": pystac.MediaType.TEXT, + "preview": pystac.MediaType.PNG, +} + +ITEM_ROLES_MAP = { + "tif": ["data", "visual"], + "tif_xml": ["metadata"], + "tif_aux_xml": ["metadata"], + "tif_ovr": ["metadata"], + "tif_vat_cpg": ["metadata"], + "tif_vat_dbf": ["metadata"], + "legend_txt": ["metadata"], + "tif_lyr": ["metadata"], + "tfw": ["metadata"], + "xml": ["metadata"], + "readme_txt": ["metadata"], + "preview": ["thumbnail"], +} + +ITEM_TITLE_MAP = { + "tif": "Single Band Land Classification {label}", + "tif_xml": "TIFF Metadata {label}", + "tif_aux_xml": "TIFF Statistics {label}", + "tif_ovr": "Pyramid {label}", + "tif_vat_cpg": "Encoding {label}", + "tif_vat_dbf": "Database {label}", + "legend_txt": "Legends {label}", + "tif_lyr": "Legend Layer {label}", + "tfw": "World File {label}", + "xml": "Single Band Land Classification Metadata {label}", + "readme_txt": "Description {label}", + "preview": "Single Band Land Classification Thumbnail {label}", +} + +CLC_PROVIDER = pystac.provider.Provider( + name="Copernicus Land Monitoring Service", + description=( + "The Copernicus Land Monitoring Service provides " + "geographical information on land cover and its " + "changes, land use, ground motions, vegetation state, " + "water cycle and Earth's surface energy variables to " + "a broad range of users in Europe and across the World " + "in the field of environmental terrestrial applications." + ), + roles=[ProviderRole.LICENSOR, ProviderRole.HOST], + url="https://land.copernicus.eu", +) + + +ITEM_DESCRIPTION = ( + "Corine Land Cover {year} (CLC{year}) is one of the Corine Land Cover (CLC) " + "datasets produced within the frame the Copernicus Land Monitoring Service " + "referring to land cover / land use status of year {year}. " + 'CLC service has a long-time heritage (formerly known as "CORINE Land Cover Programme"), ' + "coordinated by the European Environment Agency (EEA). It provides consistent " + "and thematically detailed information on land cover and land cover changes across Europe. " + "CLC datasets are based on the classification of satellite images produced by the national " + "teams of the participating countries - the EEA members and cooperating countries (EEA39). " + "National CLC inventories are then further integrated into a seamless land cover map of Europe. " + "The resulting European database relies on standard methodology and nomenclature with following " + "base parameters: 44 classes in the hierarchical 3-level CLC nomenclature; " + "minimum mapping unit (MMU) for status layers is 25 hectares; " + "minimum width of linear elements is 100 metres. " + "Change layers have higher resolution, i.e. minimum mapping unit (MMU) is 5 hectares " + "for Land Cover Changes (LCC), and the minimum width of linear elements is 100 metres. " + "The CLC service delivers important data sets supporting the implementation of key priority " + "areas of the Environment Action Programmes of the European Union as e.g. protecting ecosystems, " + "halting the loss of biological diversity, tracking the impacts of climate change, " + "monitoring urban land take, assessing developments in agriculture or dealing with " + "water resources directives. CLC belongs to the Pan-European component of the " + "Copernicus Land Monitoring Service (https://land.copernicus.eu/), part of the " + "European Copernicus Programme coordinated by the European Environment Agency, " + "providing environmental information from a combination of air- and space-based observation " + "systems and in-situ monitoring. Additional information about CLC product description including " + "mapping guides can be found at https://land.copernicus.eu/user-corner/technical-library/. " + "CLC class descriptions can be found at " + "https://land.copernicus.eu/user-corner/technical-library/corine-land-cover-nomenclature-guidelines/html/." +) diff --git a/scripts/clc/item.py b/scripts/clc/item.py new file mode 100644 index 0000000..c510520 --- /dev/null +++ b/scripts/clc/item.py @@ -0,0 +1,221 @@ +import logging +import os +import re +from datetime import UTC, datetime + +import pystac +import pystac.item +import pystac.link +import rasterio as rio +import rasterio.warp +from pystac.extensions.projection import ProjectionExtension +from shapely.geometry import box, mapping + +from .constants import ( + CLC_PROVIDER, + CLMS_LICENSE, + COLLECTION_ID, + DOM_MAP, + ITEM_DESCRIPTION, + ITEM_MEDIA_TYPE_MAP, + ITEM_ROLES_MAP, + ITEM_TITLE_MAP, + STAC_DIR, + WORKING_DIR, +) + +LOGGER = logging.getLogger(__name__) + + +def deconstruct_clc_name(filename: str) -> dict[str]: + filename_split = {"dirname": os.path.dirname(filename), "basename": os.path.basename(filename)} + p = re.compile("^(?P[A-Z0-9a-z_-]*)\\.(?P.*)$") + m = p.search(filename_split["basename"]) + + if m: + filename_split |= m.groupdict() + + p = re.compile( + "U(?P[0-9]{4})_" + "(?PCLC|CHA)(?P[0-9]{4})_" + "V(?P[0-9]{4})_(?P[0-9a-z]*)" + "_?(?P[A-Z]*)?" + "_?(?P[A-Z]*)?" + ) + m = p.search(filename_split["id"]) + + if m: + filename_split |= m.groupdict() + + return filename_split + + +def create_item_asset(asset_file: str, dom_code: str) -> pystac.Asset: + filename_elements = deconstruct_clc_name(asset_file) + + suffix = filename_elements["suffix"].replace(".", "_") + + if id.startswith("readme"): + key = "readme_" + suffix + elif id.endswith("QGIS"): + key = "legend_" + suffix + else: + key = suffix + + label = DOM_MAP[dom_code] + + asset = pystac.Asset( + href=asset_file, + title=ITEM_TITLE_MAP[key].format(label=label), + media_type=ITEM_MEDIA_TYPE_MAP[key], + roles=ITEM_ROLES_MAP[key], + ) + return f"{filename_elements['id']}_{suffix}", asset + + +def get_img_paths(data_root: str) -> list[str]: + img_paths = [] + for root, _, files in os.walk(data_root): + if root.endswith(("DATA", "French_DOMs")): + for file in files: + if file.endswith(".tif"): + img_paths.append(os.path.join(root, file)) + + return img_paths + + +def get_item_asset_files(data_root: str, img_path: str) -> list[str]: + clc_name_elements = deconstruct_clc_name(img_path) + clc_id = clc_name_elements["id"] + dom_code = clc_name_elements["DOM_code"] + + asset_files = [] + + for root, _, files in os.walk(data_root): + if not dom_code and "French_DOMs" in root: + continue + + if dom_code and "Legend" in root and "French_DOMs" not in root: + continue + + if "U{update_campaign}_{theme}{reference_year}_V{release_year}".format(**clc_name_elements).lower() not in root: + continue + + for file in files: + if ( + file.startswith(f"{clc_id}.") + or file.endswith( + ( + f"{dom_code}.tif.lyr", + "QGIS.txt", + ) + ) + or file == f"readme_{clc_id}.txt" + ): + asset_files.append(os.path.join(root, file)) + + return asset_files + + +def project_bbox(src: rio.io.DatasetReader, dst_crs: rio.CRS) -> tuple[float]: + return rio.warp.transform_bounds(src.crs, dst_crs, *src.bounds) + + +def project_data_window_bbox( + src: rio.io.DatasetReader, dst_crs: rio.CRS, dst_resolution: tuple = (0.25, 0.25) +) -> tuple[float]: + data, transform = rio.warp.reproject( + source=src.read(), + src_transform=src.transform, + src_crs=src.crs, + dst_crs=dst_crs, + dst_nodata=src.nodata, + dst_resolution=dst_resolution, + resampling=rio.warp.Resampling.max, + ) + + data_window = rio.windows.get_data_window(data, nodata=src.nodata) + return rio.windows.bounds(data_window, transform=transform) + + +def create_item(img_path: str, data_root: str) -> pystac.Item: + clc_name_elements = deconstruct_clc_name(img_path) + + asset_files = get_item_asset_files(data_root, img_path) + asset_files = [f for f in asset_files if not f.endswith("aux")] + year = clc_name_elements.get("reference_year") + props = { + "description": ITEM_DESCRIPTION.format(year=year), + "created": None, + "providers": CLC_PROVIDER.to_dict(), + } + + with rio.open(img_path) as img: + if clc_name_elements["DOM_code"]: + bbox = project_bbox(img, dst_crs=rio.CRS.from_epsg(4326)) + else: + bbox = project_data_window_bbox(img, dst_crs=rio.CRS.from_epsg(4326)) + + params = { + "id": clc_name_elements.get("id"), + "bbox": bbox, + "geometry": mapping(box(*bbox)), + "datetime": None, + "start_datetime": datetime(int(year), 1, 1, microsecond=0, tzinfo=UTC), + "end_datetime": datetime(int(year), 12, 31, microsecond=0, tzinfo=UTC), + "properties": props, + } + + item = pystac.Item(**params) + + for asset_file in asset_files: + try: + key, asset = create_item_asset(asset_file, DOM_code=clc_name_elements.get("DOM_code")) + item.add_asset( + key=key, + asset=asset, + ) + except KeyError as error: + LOGGER.error("An error occured:", error) + + # TODO: "Thumbnail" was originally put at collection level in the template, + # while it should perhaps be at item level? Individual previews should be added to each item + key = "preview" + asset = pystac.Asset( + href="https://sdi.eea.europa.eu/public/catalogue-graphic-overview/960998c1-1870-4e82-8051-6485205ebbac.png", + title=ITEM_TITLE_MAP["preview"].format(label=clc_name_elements["DOM_code"]), + media_type=ITEM_MEDIA_TYPE_MAP[key], + roles=ITEM_ROLES_MAP[key], + ) + + item.add_asset(key=key, asset=asset) + + proj_ext = ProjectionExtension.ext(item.assets[os.path.basename(img_path).replace(".", "_")], add_if_missing=True) + proj_ext.apply( + epsg=rio.crs.CRS(img.crs).to_epsg(), + bbox=img.bounds, + shape=list(img.shape), + transform=[*list(img.transform), 0.0, 0.0, 1.0], + ) + + clms_catalog_link = pystac.link.Link( + rel=pystac.RelType.ROOT, + target=pystac.STACObject.from_file(os.path.join(WORKING_DIR, f"{STAC_DIR}/clms_catalog.json")), + ) + collection_link = pystac.link.Link( + rel=pystac.RelType.COLLECTION, + target=pystac.STACObject.from_file( + os.path.join(WORKING_DIR, f"{STAC_DIR}/{COLLECTION_ID}/{COLLECTION_ID}.json") + ), + ) + item_parent_link = pystac.link.Link( + rel=pystac.RelType.PARENT, + target=pystac.STACObject.from_file( + os.path.join(WORKING_DIR, f"{STAC_DIR}/{COLLECTION_ID}/{COLLECTION_ID}.json") + ), + ) + + links = [CLMS_LICENSE, clms_catalog_link, item_parent_link, collection_link] + item.add_links(links) + + return item diff --git a/scripts/euhydro/__init__.py b/scripts/euhydro/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/euhydro/collection.py b/scripts/euhydro/collection.py new file mode 100644 index 0000000..dbcb7d5 --- /dev/null +++ b/scripts/euhydro/collection.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import json +import logging +import os + +import pystac +from jsonschema import Draft7Validator +from jsonschema.exceptions import best_match +from pystac.extensions.projection import ProjectionExtension +from pystac.media_type import MediaType +from referencing import Registry, Resource + +from .constants import ( + CLMS_LICENSE, + COLLECTION_DESCRIPTION, + COLLECTION_EXTENT, + COLLECTION_ID, + COLLECTION_KEYWORDS, + COLLECTION_TITLE, + EUHYDRO_HOST_AND_LICENSOR, + STAC_DIR, + WORKING_DIR, +) + +LOGGER = logging.getLogger(__name__) + + +class CollectionCreationError(Exception): + pass + + +def get_stac_validator(product_schema: str) -> Draft7Validator: + with open(product_schema, encoding="utf-8") as f: + schema = json.load(f) + registry = Registry().with_resources( + [("http://example.com/schema.json", Resource.from_contents(schema))], + ) + return Draft7Validator({"$ref": "http://example.com/schema.json"}, registry=registry) + + +def get_files(root: str, file_extension: str) -> list[str]: + files = [] + for dirpath, _, filenames in os.walk(root): + files += [os.path.join(dirpath, filename) for filename in filenames if filename.endswith(f".{file_extension}")] + return files + + +def get_gdb(root: str) -> list[str]: + gdb_dirs = [] + for dirpath, dirnames, _ in os.walk(root): + if dirnames: + gdb_dirs += [os.path.join(dirpath, dirname) for dirname in dirnames if dirname.endswith(".gdb")] + return gdb_dirs + + +def create_asset(filename, asset_path): + extension = filename.split(".")[-1] + asset_id = filename.replace(".", "_") + media_type_map = { + "gpkg": MediaType.GEOPACKAGE, + "gdb": "application/x-filegdb", + "xml": MediaType.XML, + "pdf": MediaType.PDF, + } + role_map = { + "gpkg": ["data"], + "gdb": ["data"], + "xml": ["metadata"], + "pdf": ["metadata"], + } + title = " ".join([word.capitalize() for word in asset_id.split("_")[:-1]]) + return asset_id, pystac.Asset( + href=asset_path, media_type=media_type_map[extension], title=title, roles=role_map[extension] + ) + + +def collect_assets(root: str) -> list[str]: + asset_list = get_files(root, "xml") + get_files(root, "pdf") + get_files(root, "gpkg") + get_gdb(root) + assets = {} + for asset_path in asset_list: + _, tail = os.path.split(asset_path) + asset_id, asset = create_asset(tail, asset_path) + if asset_id not in assets: + assets[asset_id] = asset + return assets + + +def add_summaries_to_collection(collection: pystac.Collection, epsg_list: list[int]) -> None: + summaries = ProjectionExtension.summaries(collection, add_if_missing=True) + summaries.epsg = epsg_list + + +def add_links_to_collection(collection: pystac.Collection, link_list: list[pystac.Link]) -> None: + for link in link_list: + collection.links.append(link) + + +def add_assets_to_collection(collection: pystac.Collection, asset_dict: dict[str, pystac.Asset]) -> None: + for key, asset in asset_dict.items(): + collection.add_asset(key, asset) + + +def create_collection(euhydro_root: str) -> pystac.Collection: + try: + collection = pystac.Collection( + id=COLLECTION_ID, + description=COLLECTION_DESCRIPTION, + extent=COLLECTION_EXTENT, + title=COLLECTION_TITLE, + keywords=COLLECTION_KEYWORDS, + providers=[EUHYDRO_HOST_AND_LICENSOR], + ) + + # summaries + epsg_list = [3035] + add_summaries_to_collection(collection, epsg_list) + + # links + link_list = [CLMS_LICENSE] + add_links_to_collection(collection, link_list) + + # assets + assets = collect_assets(euhydro_root) + add_assets_to_collection(collection, assets) + + # update links + collection.set_self_href(os.path.join(WORKING_DIR, f"{STAC_DIR}/{COLLECTION_ID}/{collection.id}.json")) + catalog = pystac.read_file(f"{WORKING_DIR}/{STAC_DIR}/clms_catalog.json") + collection.set_root(catalog) + collection.set_parent(catalog) + except Exception as error: + raise CollectionCreationError(f"Reason: {error}") + return collection + + +def create_euhydro_collection(euhydro_root: str) -> None: + try: + collection = create_collection(euhydro_root) + validator = get_stac_validator("schema/products/eu-hydro.json") + error_msg = best_match(validator.iter_errors(collection.to_dict())) + assert error_msg is None, f"Failed to create {collection.id} collection. Reason: {error_msg}." + collection.save_object() + except (AssertionError, CollectionCreationError) as error: + LOGGER.error(error) diff --git a/scripts/euhydro/constants.py b/scripts/euhydro/constants.py new file mode 100644 index 0000000..4795cd2 --- /dev/null +++ b/scripts/euhydro/constants.py @@ -0,0 +1,50 @@ +import os +from datetime import datetime +from typing import Final + +import pystac +from pystac.link import Link +from pystac.provider import ProviderRole + +COLLECTION_ID = "eu-hydro" +COLLECTION_DESCRIPTION = ( + "EU-Hydro is a dataset for all EEA38 countries and the United Kingdom providing photo-interpreted river network," + " consistent of surface interpretation of water bodies (lakes and wide rivers), and a drainage model (also called" + " Drainage Network), derived from EU-DEM, with catchments and drainage lines and nodes." +) +COLLECTION_EXTENT = pystac.Extent( + spatial=pystac.SpatialExtent([[-61.906047, -21.482245, 55.935919, 71.409109]]), + temporal=pystac.TemporalExtent([[datetime(year=2006, month=1, day=1), datetime(year=2012, month=12, day=31)]]), +) +COLLECTION_TITLE = "EU-Hydro River Network Database" +COLLECTION_KEYWORDS = [ + "Hydrography", + "Land cover", + "River", + "Environment", + "Ocean", + "Catchment area", + "Land", + "Hydrographic network", + "Drainage system", + "Hydrology", + "Landscape alteration", + "Inland water", + "Canal", + "Drainage", + "Catchment", + "Water body", +] +EUHYDRO_HOST_AND_LICENSOR: Final[pystac.Provider] = pystac.Provider( + name="Copernicus Land Monitoring Service", + description=( + "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land" + " use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of" + " users in Europe and across the World in the field of environmental terrestrial applications." + ), + roles=[ProviderRole.LICENSOR, ProviderRole.HOST], + url="https://land.copernicus.eu", +) +CLMS_LICENSE: Final[Link] = Link(rel="license", target="https://land.copernicus.eu/en/data-policy") +WORKING_DIR = os.getcwd() +STAC_DIR = "stac_tests" diff --git a/scripts/n2k/__init__.py b/scripts/n2k/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/n2k/collection.py b/scripts/n2k/collection.py new file mode 100644 index 0000000..fc71bc2 --- /dev/null +++ b/scripts/n2k/collection.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import json +import logging +import os + +import pystac +from jsonschema import Draft7Validator +from jsonschema.exceptions import best_match +from pystac import MediaType +from pystac.extensions.projection import ProjectionExtension +from referencing import Registry, Resource + +from .constants import ( + CLMS_LICENSE, + COLLECTION_DESCRIPTION, + COLLECTION_EXTENT, + COLLECTION_ID, + COLLECTION_KEYWORDS, + COLLECTION_TITLE, + N2K_HOST_AND_LICENSOR, + STAC_DIR, + WORKING_DIR, +) + +LOGGER = logging.getLogger(__name__) + + +class CollectionCreationError(Exception): + pass + + +def get_stac_validator(product_schema: str) -> Draft7Validator: + with open(product_schema, encoding="utf-8") as f: + schema = json.load(f) + registry = Registry().with_resources( + [("http://example.com/schema.json", Resource.from_contents(schema))], + ) + return Draft7Validator({"$ref": "http://example.com/schema.json"}, registry=registry) + + +def get_files(n2k_root: str, file_extension: str) -> list[str]: + files = [] + for dirpath, _, filenames in os.walk(n2k_root): + files += [os.path.join(dirpath, filename) for filename in filenames if filename.endswith(f".{file_extension}")] + return files + + +def get_gdb(n2k_root: str) -> list[str]: + gdb_dirs = [] + for dirpath, dirnames, _ in os.walk(n2k_root): + if dirnames: + gdb_dirs += [os.path.join(dirpath, dirname) for dirname in dirnames if dirname.endswith(".gdb")] + return gdb_dirs + + +def create_asset(filename, asset_path): + extension = filename.split(".")[-1] + asset_id = filename.replace(".", "_") + year = filename.split("_")[1] + file_format = asset_id.split("_")[-2].upper() + media_type_map = { + "zip": "application/zip", + "gpkg": MediaType.GEOPACKAGE, + "gdb": "application/x-filegdb", + "xml": MediaType.XML, + "lyr": "application/octet-stream", + "qml": "application/octet-stream", + "sld": "application/octet-stream", + } + title_map = { + "zip": f"Compressed Natura 2000 {year} Land Cover/Land Use Status {file_format}", + "gpkg": f"Natura 2000 {year} Land Cover/Land Use Status {file_format}", + "gdb": f"Natura 2000 {year} Land Cover/Land Use Status {file_format}", + "xml": f"Natura 2000 {year} Land Cover/Land Use Status Metadata", + "lyr": f"Natura 2000 {year} Land Cover/Land Use Status ArcGIS Layer File", + "qml": f"Natura 2000 {year} Land Cover/Land Use Status QGIS Layer File", + "sld": f"Natura 2000 {year} Land Cover/Land Use Status OGC Layer File", + } + role_map = { + "zip": ["data"], + "gpkg": ["data"], + "gdb": ["data"], + "xml": ["metadata"], + "lyr": ["metadata"], + "qml": ["metadata"], + "sld": ["metadata"], + } + return asset_id, pystac.Asset( + href=asset_path, media_type=media_type_map[extension], title=title_map[extension], roles=role_map[extension] + ) + + +def collect_assets(n2k_root: str) -> list[str]: + asset_list = ( + get_files(n2k_root, "xml") + + get_files(n2k_root, "lyr") + + get_files(n2k_root, "qml") + + get_files(n2k_root, "sld") + + get_files(n2k_root, "gpkg") + + get_gdb(n2k_root) + + get_files(n2k_root, "zip") + ) + assets = {} + for asset_path in asset_list: + _, tail = os.path.split(asset_path) + asset_id, asset = create_asset(tail, asset_path) + if asset_id not in assets: + assets[asset_id] = asset + return assets + + +def add_summaries_to_collection(collection: pystac.Collection, epsg_list: list[int]) -> None: + summaries = ProjectionExtension.summaries(collection, add_if_missing=True) + summaries.epsg = epsg_list + + +def add_links_to_collection(collection: pystac.Collection, link_list: list[pystac.Link]) -> None: + for link in link_list: + collection.links.append(link) + + +def add_assets_to_collection(collection: pystac.Collection, asset_dict: dict[str, pystac.Asset]) -> None: + for key, asset in asset_dict.items(): + collection.add_asset(key, asset) + + +def create_collection(n2k_root: str) -> pystac.Collection: + try: + collection = pystac.Collection( + id=COLLECTION_ID, + description=COLLECTION_DESCRIPTION, + extent=COLLECTION_EXTENT, + title=COLLECTION_TITLE, + keywords=COLLECTION_KEYWORDS, + providers=[N2K_HOST_AND_LICENSOR], + ) + + # summaries + epsg_list = [3035] + add_summaries_to_collection(collection, epsg_list) + + # links + link_list = [CLMS_LICENSE] + add_links_to_collection(collection, link_list) + + # assets + assets = collect_assets(n2k_root) + add_assets_to_collection(collection, assets) + + # update links + collection.set_self_href(os.path.join(WORKING_DIR, f"{STAC_DIR}/{COLLECTION_ID}/{collection.id}.json")) + catalog = pystac.read_file(f"{WORKING_DIR}/{STAC_DIR}/clms_catalog.json") + collection.set_root(catalog) + collection.set_parent(catalog) + except Exception as error: + raise CollectionCreationError(f"Reasom: {error}") + return collection + + +def create_n2k_collection(n2k_root: str) -> None: + try: + collection = create_collection(n2k_root) + validator = get_stac_validator("schema/products/n2k.json") + error_msg = best_match(validator.iter_errors(collection.to_dict())) + assert error_msg is None, f"Failed to create {collection.id} collection. Reason: {error_msg}." + collection.save_object() + except (AssertionError, CollectionCreationError) as error: + LOGGER.error(error) diff --git a/scripts/n2k/constants.py b/scripts/n2k/constants.py new file mode 100644 index 0000000..2f00773 --- /dev/null +++ b/scripts/n2k/constants.py @@ -0,0 +1,44 @@ +import os +from datetime import datetime +from typing import Final + +import pystac +from pystac.link import Link +from pystac.provider import ProviderRole + +COLLECTION_ID = "natura2000" +COLLECTION_DESCRIPTION = ( + "The Copernicus Land Cover/Land Use (LC/LU) status map as part of the Copernicus Land Monitoring Service (CLMS)" + " Local Component, tailored to the needs of biodiversity monitoring in selected Natura2000 sites (4790 sites of" + " natural and semi-natural grassland formations listed in Annex I of the Habitats Directive) including a 2km buffer" + " zone surrounding the sites and covering an area of 631.820 km² across Europe. LC/LU is extracted from VHR" + " satellite data and other available data." +) +COLLECTION_EXTENT = pystac.Extent( + spatial=pystac.SpatialExtent([[-16.82, 27.87, 33.17, 66.79]]), + temporal=pystac.TemporalExtent([[datetime(year=2006, month=1, day=1), None]]), +) +COLLECTION_TITLE = "Natura 2000 Land Cover/Land Use Status" +COLLECTION_KEYWORDS = [ + "Copernicus", + "Satellite image interpretation", + "Land monitoring", + "Land", + "Landscape alteration", + "Land use", + "Land cover", + "Landscape", +] +N2K_HOST_AND_LICENSOR: Final[pystac.Provider] = pystac.Provider( + name="Copernicus Land Monitoring Service", + description=( + "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land" + " use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of" + " users in Europe and across the World in the field of environmental terrestrial applications." + ), + roles=[ProviderRole.LICENSOR, ProviderRole.HOST], + url="https://land.copernicus.eu", +) +CLMS_LICENSE: Final[Link] = Link(rel="license", target="https://land.copernicus.eu/en/data-policy") +WORKING_DIR = os.getcwd() +STAC_DIR = "stac_tests" diff --git a/scripts/uabh/__init__.py b/scripts/uabh/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/uabh/collection.py b/scripts/uabh/collection.py new file mode 100644 index 0000000..80b1199 --- /dev/null +++ b/scripts/uabh/collection.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import json +import logging +import os +from enum import Enum + +import pystac +from jsonschema import Draft7Validator +from jsonschema.exceptions import best_match +from pystac.extensions.item_assets import AssetDefinition, ItemAssetsExtension +from pystac.extensions.projection import ProjectionExtension +from pystac.link import Link +from pystac.media_type import MediaType +from referencing import Registry, Resource + +from .constants import ( + CLMS_LICENSE, + COLLECTION_DESCRIPTION, + COLLECTION_EXTENT, + COLLECTION_ID, + COLLECTION_KEYWORD, + COLLECTION_TITLE, + HOST_AND_LICENSOR, + STAC_DIR, + WORKING_DIR, +) + +LOGGER = logging.getLogger(__name__) + + +class CollectionCreationError(Exception): + pass + + +class UABHItemAssets(Enum): + dataset = AssetDefinition({"title": "Building height raster", "media_type": MediaType.GEOTIFF, "roles": ["data"]}) + metadata = AssetDefinition( + {"title": "Building height metadata", "media_type": MediaType.XML, "roles": ["metadata"]} + ) + quality_check_report = AssetDefinition( + {"title": "Quality check report", "media_type": MediaType.PDF, "roles": ["metadata"]} + ) + quality_control_report = AssetDefinition( + {"title": "Quality control report", "media_type": MediaType.PDF, "roles": ["metadata"]} + ) + pixel_based_info_shp = AssetDefinition( + {"title": "Pixel based info shape format", "media_type": "application/octet-stream", "roles": ["metadata"]} + ) + pixel_based_info_shx = AssetDefinition( + {"title": "Pixel based info shape index", "media_type": "application/octet-stream", "roles": ["metadata"]} + ) + pixel_based_info_dbf = AssetDefinition( + {"title": "Pixel based info attribute", "media_type": "application/x-dbf", "roles": ["metadata"]} + ) + pixel_based_info_prj = AssetDefinition( + {"title": "Pixel based info projection description", "media_type": "text/plain", "roles": ["metadata"]} + ) + pixel_based_info_cpg = AssetDefinition( + {"title": "Pixel based info character encoding", "media_type": "text/plain", "roles": ["metadata"]} + ) + compressed_dataset = AssetDefinition( + {"title": "Compressed building height raster", "media_type": "application/zip", "roles": ["data"]} + ) + + +def get_stac_validator(product_schema: str) -> Draft7Validator: + with open(product_schema, encoding="utf-8") as f: + schema = json.load(f) + registry = Registry().with_resources( + [("http://example.com/schema.json", Resource.from_contents(schema))], + ) + return Draft7Validator({"$ref": "http://example.com/schema.json"}, registry=registry) + + +def create_core_collection() -> pystac.Collection: + return pystac.Collection( + id=COLLECTION_ID, + description=COLLECTION_DESCRIPTION, + extent=COLLECTION_EXTENT, + title=COLLECTION_TITLE, + keywords=COLLECTION_KEYWORD, + providers=[HOST_AND_LICENSOR], + ) + + +def add_summaries_to_collection(collection: pystac.Collection, epsg_list: list[int]) -> None: + summaries = ProjectionExtension.summaries(collection, add_if_missing=True) + summaries.epsg = epsg_list + + +def add_item_assets_to_collection(collection: pystac.Collection, item_asset_class: Enum) -> None: + item_assets = ItemAssetsExtension.ext(collection, add_if_missing=True) + item_assets.item_assets = {asset.name: asset.value for asset in item_asset_class} + + +def add_links_to_collection(collection: pystac.Collection, link_list: list[Link]) -> None: + for link in link_list: + collection.links.append(link) + + +def add_items_to_collection(collection: pystac.Collection, item_list: list[str]) -> None: + for item in item_list: + stac_object = pystac.read_file(item) + collection.add_item(stac_object, title=stac_object.id) + + +def create_collection(item_list: list[str]) -> None: + try: + collection = create_core_collection() + + # summaries + epsg_list = [3035] + add_summaries_to_collection(collection, epsg_list) + + # extensions + add_item_assets_to_collection(collection, UABHItemAssets) + + # links + link_list = [CLMS_LICENSE] + add_links_to_collection(collection, link_list) + + # add items + add_items_to_collection(collection, item_list) + + # add self, root and parent links + collection.set_self_href(os.path.join(WORKING_DIR, f"{STAC_DIR}/{collection.id}/{collection.id}.json")) + catalog = pystac.read_file(f"{WORKING_DIR}/{STAC_DIR}/clms_catalog.json") + collection.set_root(catalog) + collection.set_parent(catalog) + except Exception as error: + raise CollectionCreationError(f"Failed to create Urban Atlas Building Height collection. Reason: {error}.") + return collection + + +def create_uabh_collection(item_list: list[str], validator: Draft7Validator) -> None: + try: + collection = create_collection(item_list) + error_msg = best_match(validator.iter_errors(collection.to_dict())) + assert error_msg is None, f"Failed to create {collection.id} collection. Reason: {error_msg}." + collection.save_object() + except (AssertionError, CollectionCreationError) as error: + LOGGER.error(error) diff --git a/scripts/uabh/constants.py b/scripts/uabh/constants.py new file mode 100644 index 0000000..d01b2dc --- /dev/null +++ b/scripts/uabh/constants.py @@ -0,0 +1,44 @@ +import os +from datetime import datetime +from typing import Final + +import pystac +from pystac.link import Link +from pystac.provider import ProviderRole + +STAC_DIR = "stac_tests" +WORKING_DIR = os.getcwd() +CLMS_CATALOG_LINK: Final[Link] = Link( + rel=pystac.RelType.ROOT, target=pystac.STACObject.from_file(os.path.join(WORKING_DIR, "stacs/clms_catalog.json")) +) +CLMS_LICENSE: Final[Link] = Link(rel="license", target="https://land.copernicus.eu/en/data-policy") +COLLECTION_DESCRIPTION = "Urban Atlas building height over capital cities." +COLLECTION_EXTENT = pystac.Extent( + spatial=pystac.SpatialExtent([[-22.13, 35.07, 33.48, 64.38]]), + temporal=pystac.TemporalExtent([[datetime(year=2012, month=1, day=1), None]]), +) +COLLECTION_ID = "urban-atlas-building-height" +COLLECTION_KEYWORD = ["Buildings", "Building height", "Elevation"] +COLLECTION_LINK: Final[Link] = Link( + rel=pystac.RelType.COLLECTION, + target=pystac.STACObject.from_file(os.path.join(WORKING_DIR, f"stacs/{COLLECTION_ID}/{COLLECTION_ID}.json")), +) +COLLECTION_TITLE = "Urban Atlas Building Height 10m" +HOST_AND_LICENSOR: Final[pystac.Provider] = pystac.Provider( + name="Copernicus Land Monitoring Service", + description=( + "The Copernicus Land Monitoring Service provides " + "geographical information on land cover and its " + "changes, land use, ground motions, vegetation state, " + "water cycle and Earth's surface energy variables to " + "a broad range of users in Europe and across the " + "World in the field of environmental terrestrial " + "applications." + ), + roles=[ProviderRole.LICENSOR, ProviderRole.HOST], + url="https://land.copernicus.eu", +) +ITEM_PARENT_LINK: Final[Link] = Link( + rel=pystac.RelType.PARENT, + target=pystac.STACObject.from_file(os.path.join(WORKING_DIR, f"stacs/{COLLECTION_ID}/{COLLECTION_ID}.json")), +) diff --git a/scripts/uabh/item.py b/scripts/uabh/item.py new file mode 100644 index 0000000..8c84c05 --- /dev/null +++ b/scripts/uabh/item.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import json +import logging +import os +import re +import xml.etree.ElementTree as ETree +from datetime import datetime +from glob import glob + +import pystac +import rasterio as rio +from jsonschema import Draft7Validator +from jsonschema.exceptions import best_match +from pystac.extensions.projection import ProjectionExtension +from pystac.media_type import MediaType +from rasterio.coords import BoundingBox +from rasterio.crs import CRS +from rasterio.warp import transform_bounds +from referencing import Registry, Resource +from shapely.geometry import Polygon, box, mapping + +from .constants import ( + CLMS_CATALOG_LINK, + CLMS_LICENSE, + COLLECTION_ID, + COLLECTION_LINK, + HOST_AND_LICENSOR, + ITEM_PARENT_LINK, + STAC_DIR, + WORKING_DIR, +) + +LOGGER = logging.getLogger(__name__) + + +class ItemCreationError(Exception): + pass + + +def get_metadata_from_tif(root_dir: str, product_id: str) -> tuple[BoundingBox, CRS, int, int]: + tif_path = os.path.join(root_dir, f"Dataset/{product_id}.tif") + with rio.open(tif_path) as tif: + bounds = tif.bounds + crs = tif.crs + height = tif.height + width = tif.width + return (bounds, crs, height, width) + + +def str_to_datetime(datetime_str: str): + year, month, day = datetime_str[0:10].split("-") + return datetime(year=int(year), month=int(month), day=int(day)) + + +def get_namespace(tag: str, xml_string: str) -> str: + return re.search(r"xmlns:" + tag + '="([^"]+)"', xml_string).group(0).split("=")[1][1:-1] + + +def get_metadata_from_xml(xml: str) -> tuple[datetime, datetime, datetime]: + with open(xml, encoding="utf-8") as f: + xml_string = f.read() + gmd_namespace = get_namespace("gmd", xml_string) + gml_namespace = get_namespace("gml", xml_string) + tree = ETree.parse(xml) + root = tree.getroot() + start_datetime = root.findall("".join((".//{", gml_namespace, "}beginPosition")))[0].text # noqa: FLY002 + end_datetime = root.findall("".join((".//{", gml_namespace, "}endPosition")))[0].text # noqa: FLY002 + created = root.findall( + "".join( # noqa: FLY002 + ( + ".//{", + gmd_namespace, + "}CI_DateTypeCode[@codeListValue='creation']....//{", + gmd_namespace, + "}date/*", + ) + ) + )[0].text + return (str_to_datetime(start_datetime), str_to_datetime(end_datetime), str_to_datetime(created)) + + +def get_geom_wgs84(bounds: BoundingBox, crs: CRS) -> Polygon: + bbox = rio.coords.BoundingBox( + *transform_bounds(crs.to_epsg(), 4326, bounds.left, bounds.bottom, bounds.right, bounds.top) + ) + return box(*(bbox.left, bbox.bottom, bbox.right, bbox.top)) + + +def get_description(product_id: str) -> str: + _, city, year, _, version = product_id.split("_") + return f"{year[2:]} {city.title()} building height {version}" + + +def get_files(uabh_root: str, city_code: str, asset_type: str) -> list[str]: + files = [] + for dirpath, _, filenames in os.walk(uabh_root): + files += [ + os.path.join(dirpath, filename) + for filename in filenames + if filename.startswith(city_code) and dirpath.endswith(asset_type) + ] + return files + + +def get_zip(uabh_root: str, city_code: str) -> str: + files = [] + for dirpath, _, filenames in os.walk(uabh_root): + files += [ + os.path.join(dirpath, filename) + for filename in filenames + if filename.startswith(city_code) and filename.endswith(".zip") + ] + return files + + +def collect_assets(uabh_root: str, city_code: str) -> dict[str, pystac.Asset]: + asset_list = ( + get_files(uabh_root, city_code, "Dataset") + + get_files(uabh_root, city_code, "Doc") + + get_files(uabh_root, city_code, "Metadata") + + get_files(uabh_root, city_code, "PixelBasedInfo") + + get_files(uabh_root, city_code, "QC") + + get_zip(uabh_root, city_code) + ) + assets = {} + for asset_path in asset_list: + asset_id, asset = create_asset(asset_path) + assets[asset_id] = asset + return assets + + +def create_asset(asset_path: str) -> tuple[str, pystac.Asset]: + _, tail = os.path.split(asset_path) + asset_id = tail.replace(".", "_") + asset_type = asset_path.split("/")[-2] + extension = tail.split(".")[-1] + media_type_map = { + "tif": MediaType.GEOTIFF, + "xml": MediaType.XML, + "pdf": MediaType.PDF, + "zip": "application/zip", + "shp": "application/octet-stream", + "shx": "application/octet-stream", + "dbf": "application/x-dbf", + "cpg": "text/plain", + "prj": "text/plain", + } + title_map = { + "Dataset": "Building Height Dataset", + "Doc": "Quality Check Report", + "Metadata": "Building Height Dataset Metadata", + "PixelBasedInfo": f"pixel_based_info_{extension}", + "QC": "Quality Control Report", + } + role_map = { + "tif": ["data"], + "xml": ["metadata"], + "pdf": ["metadata"], + "zip": ["data"], + "shp": ["metadata"], + "shx": ["metadata"], + "dbf": ["metadata"], + "cpg": ["metadata"], + "prj": ["metadata"], + } + if extension == "zip": + title = "Compressed Building Height Metadata" + else: + title = title_map[asset_type] + return asset_id, pystac.Asset( + href=asset_path, media_type=media_type_map[extension], title=title, roles=role_map[extension] + ) + + +def create_core_item( + product_id: str, + geometry: Polygon, + start_datetime: datetime, + end_datetime: datetime, + created_datetime: datetime, + description: str, + collection: str, +): + return pystac.Item( + id=product_id, + geometry=mapping(geometry), + bbox=list(geometry.bounds), + datetime=None, + start_datetime=start_datetime, + end_datetime=end_datetime, + properties={ + "created": created_datetime.strftime("%Y-%m-%dT%H:%M:%SZ"), + "description": description, + }, + collection=collection, + ) + + +def add_providers_to_item(item: pystac.Item, provider_list: list[pystac.Provider]) -> None: + item.common_metadata.providers = provider_list + + +def add_projection_extension_to_item(item: pystac.Item, crs: CRS, bounds: BoundingBox, height: int, width: int) -> None: + projection = ProjectionExtension.ext(item, add_if_missing=True) + projection.epsg = crs.to_epsg() + projection.bbox = [int(bounds.left), int(bounds.bottom), int(bounds.right), int(bounds.top)] + projection.shape = [height, width] + + +def add_links_to_item(item: pystac.Item, link_list: list[pystac.Link]) -> None: + for link in link_list: + item.links.append(link) + + +def add_assets_to_item(item: pystac.Item, asset_dict: dict[str, pystac.Asset]) -> None: + for key, asset in asset_dict.items(): + item.add_asset(key, asset) + + +def create_item(zip_path: str) -> pystac.Item: + try: + head, tail = os.path.split(zip_path) + product_id = tail.split(".")[0].upper() + bounds, crs, height, width = get_metadata_from_tif(head, product_id) + xml_path = glob(os.path.join(head, "Metadata", f"{product_id.split('_')[0]}*.xml"))[0] + start_datetime, end_datetime, created_datetime = get_metadata_from_xml(xml_path) + geom_wgs84 = get_geom_wgs84(bounds, crs) + description = get_description(product_id) + + # create core item + item = create_core_item( + product_id, geom_wgs84, start_datetime, end_datetime, created_datetime, description, COLLECTION_ID + ) + + # common metadata + provider_list = [HOST_AND_LICENSOR] + add_providers_to_item(item, provider_list) + + # extensions + add_projection_extension_to_item(item, crs, bounds, height, width) + + # links + link_list = [CLMS_LICENSE, CLMS_CATALOG_LINK, ITEM_PARENT_LINK, COLLECTION_LINK] + add_links_to_item(item, link_list) + + # assets + asset_dict = collect_assets(head, product_id.split("_")[0]) + add_assets_to_item(item, asset_dict) + except Exception as error: + raise ItemCreationError(error) + return item + + +def create_uabh_item(zip_path: str, validator: Draft7Validator) -> None: + try: + item = create_item(zip_path) + item.set_self_href(os.path.join(WORKING_DIR, f"{STAC_DIR}/{COLLECTION_ID}/{item.id}/{item.id}.json")) + error_msg = best_match(validator.iter_errors(item.to_dict())) + assert error_msg is None, f"Failed to create {item.id} item. Reason: {error_msg}." + item.save_object() + except (AssertionError, ItemCreationError) as error: + LOGGER.error(error) + + +def get_stac_validator(product_schema: str) -> Draft7Validator: + with open(product_schema, encoding="utf-8") as f: + schema = json.load(f) + registry = Registry().with_resources( + [("http://example.com/schema.json", Resource.from_contents(schema))], + ) + return Draft7Validator({"$ref": "http://example.com/schema.json"}, registry=registry) diff --git a/scripts/vpp/__init__.py b/scripts/vpp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/vpp/collection.py b/scripts/vpp/collection.py new file mode 100644 index 0000000..859d776 --- /dev/null +++ b/scripts/vpp/collection.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import json +import logging +import os + +import pystac +import pystac.extensions +import pystac.extensions.projection +from jsonschema import Draft7Validator +from jsonschema.exceptions import best_match +from pystac.extensions.item_assets import AssetDefinition, ItemAssetsExtension +from pystac.extensions.projection import ProjectionExtension +from pystac.link import Link +from referencing import Registry, Resource + +from .constants import ( + CLMS_LICENSE, + COLLECTION_DESCRIPTION, + COLLECTION_EXTENT, + COLLECTION_ID, + COLLECTION_KEYWORDS, + COLLECTION_TITLE, + STAC_DIR, + TITLE_MAP, + VPP_HOST_AND_LICENSOR, + VPP_PRODUCER_AND_PROCESSOR, + WORKING_DIR, +) + +LOGGER = logging.getLogger(__name__) + + +class CollectionCreationError(Exception): + pass + + +def get_stac_validator(product_schema: str) -> Draft7Validator: + with open(product_schema, encoding="utf-8") as f: + schema = json.load(f) + registry = Registry().with_resources( + [("http://example.com/schema.json", Resource.from_contents(schema))], + ) + return Draft7Validator({"$ref": "http://example.com/schema.json"}, registry=registry) + + +def create_core_collection() -> pystac.Collection: + return pystac.Collection( + id=COLLECTION_ID, + description=COLLECTION_DESCRIPTION, + extent=COLLECTION_EXTENT, + title=COLLECTION_TITLE, + keywords=COLLECTION_KEYWORDS, + providers=[VPP_HOST_AND_LICENSOR, VPP_PRODUCER_AND_PROCESSOR], + ) + + +def add_summaries_to_collection(collection: pystac.Collection, epsg_list: list[int]) -> None: + summaries = ProjectionExtension.summaries(collection, add_if_missing=True) + summaries.epsg = epsg_list + + +def add_item_assets_to_collection(collection: pystac.Collection, asset_title_map: dict[str, str]) -> None: + item_assets = ItemAssetsExtension.ext(collection, add_if_missing=True) + item_assets.item_assets = { + key: AssetDefinition({"title": asset_title_map[key], "media_type": pystac.MediaType.GEOTIFF, "roles": ["data"]}) + for key in asset_title_map + } + + +def add_links_to_collection(collection: pystac.Collection, link_list: list[Link]) -> None: + for link in link_list: + collection.links.append(link) + + +def add_items_to_collection(collection: pystac.Collection, item_list: list[str]) -> None: + for item in item_list: + stac_object = pystac.read_file(item) + collection.add_item(stac_object, title=stac_object.id) + + +def create_collection(item_list: list[str]) -> pystac.Collection: + try: + collection = create_core_collection() + + # summaries + epsg_list = [ + 32620, + 32621, + 32622, + 32625, + 32626, + 32627, + 32628, + 32629, + 32630, + 32631, + 32632, + 32633, + 32634, + 32635, + 32636, + 32637, + 32638, + 32738, + 32740, + ] + add_summaries_to_collection(collection, epsg_list) + + # extensions + add_item_assets_to_collection(collection, TITLE_MAP) + + # links + link_list = [CLMS_LICENSE] + add_links_to_collection(collection, link_list) + + # add items + add_items_to_collection(collection, item_list) + + # add self, root, and parent links + collection.set_self_href(os.path.join(WORKING_DIR, f"{STAC_DIR}/{collection.id}/{collection.id}.json")) + catalog = pystac.read_file(f"{WORKING_DIR}/{STAC_DIR}/clms_catalog.json") + collection.set_root(catalog) + collection.set_parent(catalog) + except Exception as error: + raise CollectionCreationError(error) + return collection + + +def create_vpp_collection(item_list: list[str], validator: Draft7Validator) -> None: + try: + collection = create_collection(item_list) + error_msg = best_match(validator.iter_errors(collection.to_dict())) + assert error_msg is None, f"Failed to create {collection.id} collection. Reason: {error_msg}." + collection.save_object() + except (AssertionError, CollectionCreationError) as error: + LOGGER.error(error) diff --git a/scripts/vpp/constants.py b/scripts/vpp/constants.py new file mode 100644 index 0000000..6b3ef1d --- /dev/null +++ b/scripts/vpp/constants.py @@ -0,0 +1,85 @@ +import os +from datetime import datetime +from typing import Final + +import boto3 +import pystac +from pystac.link import Link +from pystac.provider import ProviderRole + +AWS_SESSION = boto3.Session(profile_name="hrvpp") +BUCKET = "HRVPP" +CLMS_LICENSE: Final[Link] = Link(rel="license", target="https://land.copernicus.eu/en/data-policy") +COLLECTION_DESCRIPTION = ( + "Vegetation Phenology and Productivity Parameters (VPP) product is part of the Copernicus Land Monitoring Service" + " (CLMS), pan-European High Resolution Vegetation Phenology and Productivity (HR-VPP) product suite. The VPP" + " product is comprised of 13 parameters that describe specific stages of the seasonal vegetation growth cycle." + " These parameters are extracted from Seasonal Trajectories of the Plant Phenology Index (PPI) derived from" + " Sentinel-2 satellite observations at 10m resolution. Since growing seasons can traverse years, VPP parameters are" + " provided for a maximum of two growing seasons per year. The parameters include (1) start of season (date, PPI" + " value and slope), (2) end of season (date, PPI value and slope), (3)length of season, (4) minimum of season, (4)" + " peak of the season (date and PPI value), (5) amplitude, (6) small integrated value and (7) large integrated" + " value." +) +COLLECTION_EXTENT = pystac.Extent( + spatial=pystac.SpatialExtent([[-25, 26, 45, 72]]), + temporal=pystac.TemporalExtent([[datetime(year=2017, month=1, day=1), None]]), +) +COLLECTION_ID = "vegetation-phenology-and-productivity" +COLLECTION_KEYWORDS = [ + "agriculture", + "clms", + "derived data", + "open data", + "phenology", + "plant phenology index", + "vegetation", +] +COLLECTION_TITLE = "Vegetation Phenology and Productivity Parameters" +STAC_DIR = "stac_tests" +TITLE_MAP = { + "AMPL": "Season Amplitude", + "EOSD": "Day of End-of-Season", + "EOSV": "Vegetation Index Value at EOSD", + "LENGTH": "Length of Season", + "LSLOPE": "Slope of The Greening Up Period", + "MAXD": "Day of Maximum-of-Season", + "MAXV": "Vegetation Index Value at MAXD", + "MINV": "Average Vegetation Index Value of Minima on Left and Right Sides of Each Season", + "QFLAG": "Quality Flag", + "RSLOPE": "Slope of The Senescent Period", + "SOSD": "Day of Start-of-Season", + "SOSV": "Vegetation Index Value at SOSD", + "SPROD": "Seasonal Productivity", + "TPROD": "Total Productivity", +} +VPP_HOST_AND_LICENSOR: Final[pystac.Provider] = pystac.Provider( + name="Copernicus Land Monitoring Service", + description=( + "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land" + " use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of" + " users in Europe and across the World in the field of environmental terrestrial applications." + ), + roles=[ProviderRole.LICENSOR, ProviderRole.HOST], + url="https://land.copernicus.eu", +) +VPP_PRODUCER_AND_PROCESSOR: Final[pystac.Provider] = pystac.Provider( + name="VITO NV", + description=( + "VITO is an independent Flemish research organisation in the area of cleantech and sustainable development." + ), + roles=[ProviderRole.PROCESSOR, ProviderRole.PRODUCER], + url="https://vito.be", +) +WORKING_DIR = os.getcwd() +CLMS_CATALOG_LINK: Final[Link] = Link( + rel=pystac.RelType.ROOT, target=pystac.STACObject.from_file(os.path.join(WORKING_DIR, "stacs/clms_catalog.json")) +) +COLLECTION_LINK: Final[Link] = Link( + rel=pystac.RelType.COLLECTION, + target=pystac.STACObject.from_file(os.path.join(WORKING_DIR, f"stacs/{COLLECTION_ID}/{COLLECTION_ID}.json")), +) +ITEM_PARENT_LINK: Final[Link] = Link( + rel=pystac.RelType.PARENT, + target=pystac.STACObject.from_file(os.path.join(WORKING_DIR, f"stacs/{COLLECTION_ID}/{COLLECTION_ID}.json")), +) diff --git a/scripts/vpp/item.py b/scripts/vpp/item.py new file mode 100644 index 0000000..140620d --- /dev/null +++ b/scripts/vpp/item.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import io +import json +import logging +import os +from datetime import datetime + +import boto3 +import pystac +import rasterio as rio +from botocore.paginate import PageIterator +from jsonschema import Draft7Validator +from jsonschema.exceptions import best_match +from pystac.extensions.projection import ProjectionExtension +from rasterio.coords import BoundingBox +from rasterio.crs import CRS +from rasterio.warp import transform_bounds +from referencing import Registry, Resource +from shapely.geometry import Polygon, box, mapping + +from .constants import ( + BUCKET, + CLMS_CATALOG_LINK, + CLMS_LICENSE, + COLLECTION_ID, + COLLECTION_LINK, + ITEM_PARENT_LINK, + STAC_DIR, + TITLE_MAP, + VPP_HOST_AND_LICENSOR, + VPP_PRODUCER_AND_PROCESSOR, + WORKING_DIR, +) + +LOGGER = logging.getLogger(__name__) + + +class ItemCreationError(Exception): + pass + + +def create_product_list(start_year: int, end_year: int) -> list[str]: + product_list = [] + for year in range(start_year, end_year + 1): + for season in ("s1", "s2"): + product_list.append(f"CLMS/Pan-European/Biophysical/VPP/v01/{year}/{season}/") + return product_list + + +def create_page_iterator(aws_session: boto3.Session, bucket: str, prefix: str) -> PageIterator: + client = aws_session.client("s3") + paginator = client.get_paginator("list_objects_v2") + return paginator.paginate(Bucket=bucket, Prefix=prefix, Delimiter="-") + + +def read_metadata_from_s3(bucket: str, key: str, aws_session: boto3.Session) -> tuple[BoundingBox, CRS, int, int]: + s3 = aws_session.resource("s3") + obj = s3.Object(bucket, key) + body = obj.get()["Body"].read() + with rio.open(io.BytesIO(body)) as tif: + bounds = tif.bounds + crs = tif.crs + height = tif.height + width = tif.width + return (bounds, crs, height, width, obj.last_modified) + + +def get_geom_wgs84(bounds: BoundingBox, crs: CRS) -> Polygon: + bbox = rio.coords.BoundingBox( + *transform_bounds(crs.to_epsg(), 4326, bounds.left, bounds.bottom, bounds.right, bounds.top) + ) + return box(*(bbox.left, bbox.bottom, bbox.right, bbox.top)) + + +def get_description(product_id: str) -> str: + product, year, _, tile_res, season = product_id.split("_") + return f"The {year} season {season[-1]} {product} product of tile {tile_res[:6]} at {tile_res[8:10]} m resolution." + + +def get_datetime(product_id: str) -> tuple[datetime, datetime]: + year = int(product_id.split("_")[1]) + return (datetime(year=year, month=1, day=1), datetime(year=year, month=12, day=31)) + + +def create_asset_href(bucket: str, asset_key: str) -> str: + return f"s3://{bucket}/" + asset_key + + +def create_asset(asset_key: str) -> pystac.Asset: + parameter = asset_key.split("_")[-1].split(".")[0] + version = asset_key.split("_")[-3] + href = create_asset_href(BUCKET, asset_key) + return pystac.Asset( + href=href, + media_type=pystac.MediaType.GEOTIFF, + title=TITLE_MAP[parameter] + f" {version}", + roles=["data"], + ) + + +def create_core_item( + product_id: str, + geometry: Polygon, + start_datetime: datetime, + end_datetime: datetime, + create_datetime: datetime, + description: str, + collection: str, +): + return pystac.Item( + id=product_id, + geometry=mapping(geometry), + bbox=list(geometry.bounds), + datetime=None, + start_datetime=start_datetime, + end_datetime=end_datetime, + properties={"created": create_datetime.strftime("%Y-%m-%dT%H:%M:%SZ"), "description": description}, + collection=collection, + ) + + +def add_providers_to_item(item: pystac.Item, provider_list: list[pystac.Provider]) -> None: + item.common_metadata.providers = provider_list + + +def add_projection_extension_to_item(item: pystac.Item, crs: CRS, bounds: BoundingBox, height: int, width: int) -> None: + projection = ProjectionExtension.ext(item, add_if_missing=True) + projection.epsg = crs.to_epsg() + projection.bbox = [int(bounds.left), int(bounds.bottom), int(bounds.right), int(bounds.top)] + projection.shape = [height, width] + + +def add_links_to_item(item: pystac.Item, link_list: list[pystac.Link]) -> None: + for link in link_list: + item.links.append(link) + + +def add_assets_to_item(item: pystac.Item, asset_dict: dict[str, pystac.Asset]) -> None: + for key, asset in asset_dict.items(): + item.add_asset(key, asset) + + +def create_item(aws_session: boto3.Session, bucket: str, tile: str) -> pystac.Item: + try: + client = aws_session.client("s3") + parameters = client.list_objects(Bucket=bucket, Prefix=tile, Delimiter=".")["CommonPrefixes"] + asset_keys = [parameter["Prefix"] + "tif" for parameter in parameters] + _, tail = os.path.split(asset_keys[0]) + product_id = "_".join((tail[:23], tail[29:31])) + bounds, crs, height, width, created = read_metadata_from_s3(bucket, asset_keys[0], aws_session) + geom_wgs84 = get_geom_wgs84(bounds, crs) + description = get_description(product_id) + start_datetime, end_datetime = get_datetime(product_id) + + # core metadata + item = create_core_item( + product_id, geom_wgs84, start_datetime, end_datetime, created, description, COLLECTION_ID + ) + + # common metadata + provider_list = [VPP_HOST_AND_LICENSOR, VPP_PRODUCER_AND_PROCESSOR] + add_providers_to_item(item, provider_list) + + # extensions + add_projection_extension_to_item(item, crs, bounds, height, width) + + # links + link_list = [CLMS_LICENSE, CLMS_CATALOG_LINK, ITEM_PARENT_LINK, COLLECTION_LINK] + add_links_to_item(item, link_list) + + # assets + assets = {os.path.split(key)[-1][:-4].lower(): create_asset(key) for key in asset_keys} + add_assets_to_item(item, assets) + except Exception as error: + raise ItemCreationError(error) + return item + + +def get_stac_validator(product_schema: str) -> Draft7Validator: + with open(product_schema, encoding="utf-8") as f: + schema = json.load(f) + registry = Registry().with_resources( + [("http://example.com/schema.json", Resource.from_contents(schema))], + ) + return Draft7Validator({"$ref": "http://example.com/schema.json"}, registry=registry) + + +def create_vpp_item(aws_session: boto3.Session, bucket: str, validator: Draft7Validator, tile: str) -> None: + try: + item = create_item(aws_session, bucket, tile) + item.set_self_href(os.path.join(WORKING_DIR, f"{STAC_DIR}/{COLLECTION_ID}/{item.id}/{item.id}.json")) + error_msg = best_match(validator.iter_errors(item.to_dict())) + assert error_msg is None, f"Failed to create {item.id} item. Reason: {error_msg}." + item.save_object() + except (AssertionError, ItemCreationError) as error: + LOGGER.error(error) diff --git a/stacs/clms_catalog.json b/stacs/clms_catalog.json index 95e157c..77e6fe5 100644 --- a/stacs/clms_catalog.json +++ b/stacs/clms_catalog.json @@ -12,7 +12,7 @@ }, { "rel": "self", - "href": "https://git.sinergise.com/sh-vas/etc-di-stac/stac/clms_catalog.json", + "href": "./clms_catalog.json", "type": "application/json" }, { diff --git a/stacs/eu-hydro/eu-hydro.json b/stacs/eu-hydro/eu-hydro.json index 945eb2b..e4bffc9 100644 --- a/stacs/eu-hydro/eu-hydro.json +++ b/stacs/eu-hydro/eu-hydro.json @@ -31,7 +31,7 @@ "name": "Copernicus Land Monitoring Service", "description": "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of users in Europe and across the World in the field of environmental terrestrial applications.", "roles": ["licensor", "host"], - "url": "https://land.copernicus.eu/en" + "url": "https://land.copernicus.eu" } ], "extent": { @@ -39,29 +39,29 @@ "bbox": [[-61.906047, -21.482245, 55.935919, 71.409109]] }, "temporal": { - "interval": [["2006-01-01T00:00:00.000Z", "2012-12-31T00:00:00.000Z"]] + "interval": [["2006-01-01T00:00:00Z", "2012-12-31T00:00:00Z"]] } }, "assets": { - "eu-hydro_v1p3_user_guide": { + "EU-HYDRO_V1p3_User_Guide_pdf": { "href": "EU_HYDRO_v13\\fgdb\\euhydro_angerman_v013_FGDB\\Documentation\\EU-HYDRO_V1p3_User_Guide.pdf", "title": "EU Hydro v1.3 user guide", "type": "application/pdf", "roles": ["metadata"] }, - "how_use_esri_fgdb_in_qgis": { + "How_use_ESRI_FGDB_in_QGIS_pdf": { "href": "EU_HYDRO_v13\\fgdb\\euhydro_angerman_v013_FGDB\\Documentation\\How_use_ESRI_FGDB_in_QGIS.pdf", "title": "How use ESRI FGDB in QGIS", "type": "application/pdf", "roles": ["metadata"] }, - "angerman_v013_FGDB": { + "angerman_v013_fgdb": { "href": "EU_HYDRO_v13\\fgdb\\euhydro_angerman_v013_FGDB\\euhydro_angerman_v013.gdb", "title": "Angerman FGDB", "type": "application/x-filegdb", "roles": ["data"] }, - "angerman_v013_GPKG": { + "angerman_v013_gpkg": { "href": "EU_HYDRO_v13\\geopackage\\euhydro_angerman_v013.gpkg", "title": "Angerman GPKG", "type": "application/geopackage+sqlite3", diff --git a/stacs/natura2000/natura2000.json b/stacs/natura2000/natura2000.json index 1a76943..055b201 100644 --- a/stacs/natura2000/natura2000.json +++ b/stacs/natura2000/natura2000.json @@ -23,7 +23,7 @@ "name": "Copernicus Land Monitoring Service", "description": "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of users in Europe and across the World in the field of environmental terrestrial applications.", "roles": ["licensor", "host"], - "url": "https://land.copernicus.eu/en" + "url": "https://land.copernicus.eu" } ], "extent": { @@ -31,7 +31,7 @@ "bbox": [[-16.82, 27.87, 33.17, 66.79]] }, "temporal": { - "interval": [["2006-01-01T00:00:00.000Z", null]] + "interval": [["2006-01-01T00:00:00Z", null]] } }, "assets": { @@ -59,25 +59,25 @@ "type": "application/zip", "roles": ["data"] }, - "N2K_2018_3035_v010_metadata": { + "N2K_2018_3035_v010_xml": { "href": "Natura2000\\N2K2018\\N2K_2018_3035_v010_fgdb\\Metadata\\N2K_2018_3035_v010.xml", "title": "Natura 2000 Land Cover/Land Use Status 2018 Metadata", "type": "application/xml", "roles": ["metadata"] }, - "N2K_2018_3035_v010_arcgis_layer": { + "N2K_2018_3035_v010_lyr": { "href": "Natura2000\\N2K2018\\N2K_2018_3035_v010_fgdb\\Symbology\\N2K_LCLU_2018_v1-4-2.lyr", "title": "Natura 2000 Land Cover/Land Use Status 2018 ArcGIS Layer File", "type": "image/tiff; application=geotiff; profile=layer", "roles": ["metadata"] }, - "N2K_2018_3035_v010_qgis_layer": { + "N2K_2018_3035_v010_qml": { "href": "Natura2000\\N2K2018\\N2K_2018_3035_v010_fgdb\\Symbology\\N2K_LCLU_2018_v1-4-2.qml", "title": "Natura 2000 Land Cover/Land Use Status 2018 QGIS Layer File", "type": "image/tiff; application=geotiff; profile=layer", "roles": ["metadata"] }, - "N2K_2018_3035_v010_ogc_layer": { + "N2K_2018_3035_v010_sld": { "href": "Natura2000\\N2K2018\\N2K_2018_3035_v010_fgdb\\Symbology\\N2K_LCLU_2018_v1-4-2.sld", "title": "Natura 2000 Land Cover/Land Use Status 2018 OGC Layer File", "type": "image/tiff; application=geotiff; profile=layer", diff --git a/stacs/urban-atlas-building-height/AL001_TIRANA_UA2012_DHM_V020/AL001_TIRANA_UA2012_DHM_V020.json b/stacs/urban-atlas-building-height/AL001_TIRANA_UA2012_DHM_V020/AL001_TIRANA_UA2012_DHM_V020.json index 1374b5e..dd5384c 100644 --- a/stacs/urban-atlas-building-height/AL001_TIRANA_UA2012_DHM_V020/AL001_TIRANA_UA2012_DHM_V020.json +++ b/stacs/urban-atlas-building-height/AL001_TIRANA_UA2012_DHM_V020/AL001_TIRANA_UA2012_DHM_V020.json @@ -68,25 +68,25 @@ } ], "assets": { - "dataset": { + "AL001_TIRANA_UA2012_DHM_V020_tif": { "href": "BuildingHeight\\USBBH2012\\Dataset\\AL001_TIRANA_UA2012_DHM_V020.tif", "type": "image/tiff; application=geotiff", "title": "Building Height Dataset", "roles": ["data"] }, - "quality_check_report": { + "AL001_TIRANA_UA2012_DHM_QC_20191031_v020_pdf": { "href": "BuildingHeight\\USBBH2012\\Doc\\AL001_TIRANA_UA2012_DHM_QC_20191031_v020.pdf", "type": "application/pdf", "title": "Quality Check Report", "roles": ["metadata"] }, - "metadata": { + "AL001_TIRANA_UA2012_DHM_metadata_v020_xml": { "href": "BuildingHeight\\USBBH2012\\Metadata\\AL001_TIRANA_UA2012_DHM_metadata_v020.xml", "type": "application/xml", "title": "Building Height Dataset Metadata", "roles": ["metadata"] }, - "compressed_dataset": { + "AL001_TIRANA_UA2012_DHM_v020_zip": { "href": "BuildingHeight\\USBBH2012\\AL001_TIRANA_UA2012_DHM_v020.zip", "type": "application/zip", "title": "Compressed Building Height Metadata", diff --git a/stacs/urban-atlas-building-height/urban-atlas-building-height.json b/stacs/urban-atlas-building-height/urban-atlas-building-height.json index 4b7125a..1c01afd 100644 --- a/stacs/urban-atlas-building-height/urban-atlas-building-height.json +++ b/stacs/urban-atlas-building-height/urban-atlas-building-height.json @@ -2,8 +2,8 @@ "type": "Collection", "stac_version": "1.0.0", "stac_extensions": [ - "https://stac-extensions.github.io/item-assets/v1.0.0/schema.json", - "https://stac-extensions.github.io/projection/v1.1.0/schema.json" + "https://stac-extensions.github.io/projection/v1.1.0/schema.json", + "https://stac-extensions.github.io/item-assets/v1.0.0/schema.json" ], "id": "urban-atlas-building-height", "title": "Urban Atlas Building Height 10m", @@ -15,7 +15,7 @@ "name": "Copernicus Land Monitoring Service", "description": "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of users in Europe and across the World in the field of environmental terrestrial applications.", "roles": ["licensor", "host"], - "url": "https://land.copernicus.eu/en" + "url": "https://land.copernicus.eu" } ], "extent": { @@ -23,7 +23,7 @@ "bbox": [[-22.13, 35.07, 33.48, 64.38]] }, "temporal": { - "interval": [["2012-01-01T00:00:00.000Z", null]] + "interval": [["2012-01-01T00:00:00Z", null]] } }, "item_assets": { @@ -38,7 +38,7 @@ "roles": ["metadata"] }, "metadata": { - "title": "Metadata", + "title": "Building height metadata", "type": "application/xml", "roles": ["metadata"] }, diff --git a/stacs/vegetation-phenology-and-productivity/VPP_2022_S2_T40KCC-010m_V105_s2/VPP_2022_S2_T40KCC-010m_V105_s2.json b/stacs/vegetation-phenology-and-productivity/VPP_2022_S2_T40KCC-010m_s2/VPP_2022_S2_T40KCC-010m_s2.json similarity index 83% rename from stacs/vegetation-phenology-and-productivity/VPP_2022_S2_T40KCC-010m_V105_s2/VPP_2022_S2_T40KCC-010m_V105_s2.json rename to stacs/vegetation-phenology-and-productivity/VPP_2022_S2_T40KCC-010m_s2/VPP_2022_S2_T40KCC-010m_s2.json index b7c28e2..5ea6c23 100644 --- a/stacs/vegetation-phenology-and-productivity/VPP_2022_S2_T40KCC-010m_V105_s2/VPP_2022_S2_T40KCC-010m_V105_s2.json +++ b/stacs/vegetation-phenology-and-productivity/VPP_2022_S2_T40KCC-010m_s2/VPP_2022_S2_T40KCC-010m_s2.json @@ -4,7 +4,7 @@ "https://stac-extensions.github.io/projection/v1.1.0/schema.json" ], "type": "Feature", - "id": "VPP_2022_S2_T40KCC-010m_V105_s2", + "id": "VPP_2022_S2_T40KCC-010m_s2", "bbox": [55.077444, -20.877262, 56.13278, -19.8938], "geometry": { "type": "Polygon", @@ -19,7 +19,7 @@ ] }, "properties": { - "description": "2022 Season 2 Vegetation Phenology and Productivity Parameters (VPP) product of Sentinel-2 tile T40KCC.", + "description": "The 2022 season 2 VPP product of tile T40KCC at 10 m resolution.", "datetime": null, "start_datetime": "2022-01-01T00:00:00.000Z", "end_datetime": "2022-12-31T00:00:00.000Z", @@ -30,6 +30,12 @@ "description": "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of users in Europe and across the World in the field of environmental terrestrial applications.", "roles": ["licensor", "host"], "url": "https://land.copernicus.eu" + }, + { + "name": "VITO NV", + "description": "VITO is an independent Flemish research organisation in the area of cleantech and sustainable development.", + "roles": ["processor", "producer"], + "url": "https://vito.be" } ], "proj:epsg": 32740, @@ -44,9 +50,8 @@ }, { "rel": "self", - "href": "./VPP_2022_S2_T40KCC-010m_V105_s2_TPROD/VPP_2022_S2_T40KCC-010m_V105_s2.json", - "type": "application/json", - "title": "Vegetation Phenology and Productivity Parameters 2022 Season 2 Tile T40KCC" + "href": "./VPP_2022_S2_T40KCC-010m_s2_TPROD/VPP_2022_S2_T40KCC-010m_s2.json", + "type": "application/json" }, { "rel": "root", @@ -71,85 +76,85 @@ "vpp_2022_s2_t40kcc-010m_v105_s2_ampl": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_AMPL.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Season Amplitude", + "title": "Season Amplitude V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_eosd": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_EOSD.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Day of End-of-Season", + "title": "Day of End-of-Season V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_eosv": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_EOSV.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Vegetation Index Value at EOSD", + "title": "Vegetation Index Value at EOSD V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_length": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_LENGTH.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Length of Season", + "title": "Length of Season V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_lslope": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_LSLOPE.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Slope of The Greening Up Period", + "title": "Slope of The Greening Up Period V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_maxd": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_MAXD.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Day of Maximum-of-Season", + "title": "Day of Maximum-of-Season V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_maxv": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_MAXV.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Vegetation Index Value at MAXD", + "title": "Vegetation Index Value at MAXD V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_minv": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_MINV.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Average Vegetation Index Value of Minima on Left and Right Sides of Each Season", + "title": "Average Vegetation Index Value of Minima on Left and Right Sides of Each Season V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_qflag": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_QFLAG.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Quality Flag", + "title": "Quality Flag V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_rslope": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_RSLOPE.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Slope of The Senescent Period", + "title": "Slope of The Senescent Period V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_sosd": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_SOSD.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Day of Start-of-Season", + "title": "Day of Start-of-Season V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_sosv": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_SOSV.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Vegetation Index Value at SOSD", + "title": "Vegetation Index Value at SOSD V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_sprod": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_SPROD.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Seasonal Productivity", + "title": "Seasonal Productivity V105", "roles": ["data"] }, "vpp_2022_s2_t40kcc-010m_v105_s2_tprod": { "href": "../../../data/vegetation-phenology-and-productivity/vpp_2022/VPP_2022_S2_T40KCC-010m_V105_s2_TPROD.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Total Productivity", + "title": "Total Productivity V105", "roles": ["data"] } } diff --git a/stacs/vegetation-phenology-and-productivity/vegetation-phenology-and-productivity.json b/stacs/vegetation-phenology-and-productivity/vegetation-phenology-and-productivity.json index ee9de8d..1b1b8a5 100644 --- a/stacs/vegetation-phenology-and-productivity/vegetation-phenology-and-productivity.json +++ b/stacs/vegetation-phenology-and-productivity/vegetation-phenology-and-productivity.json @@ -2,8 +2,8 @@ "type": "Collection", "stac_version": "1.0.0", "stac_extensions": [ - "https://stac-extensions.github.io/item-assets/v1.0.0/schema.json", - "https://stac-extensions.github.io/projection/v1.1.0/schema.json" + "https://stac-extensions.github.io/projection/v1.1.0/schema.json", + "https://stac-extensions.github.io/item-assets/v1.0.0/schema.json" ], "id": "vegetation-phenology-and-productivity", "title": "Vegetation Phenology and Productivity Parameters", @@ -23,7 +23,13 @@ "name": "Copernicus Land Monitoring Service", "description": "The Copernicus Land Monitoring Service provides geographical information on land cover and its changes, land use, ground motions, vegetation state, water cycle and Earth's surface energy variables to a broad range of users in Europe and across the World in the field of environmental terrestrial applications.", "roles": ["licensor", "host"], - "url": "https://land.copernicus.eu/en" + "url": "https://land.copernicus.eu" + }, + { + "name": "VITO NV", + "description": "VITO is an independent Flemish research organisation in the area of cleantech and sustainable development.", + "roles": ["processor", "producer"], + "url": "https://vito.be" } ], "extent": { @@ -31,7 +37,7 @@ "bbox": [[-25, 26, 45, 72]] }, "temporal": { - "interval": [["2017-01-01T00:00:00.000Z", null]] + "interval": [["2017-01-01T00:00:00Z", null]] } }, "item_assets": {