Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,7 @@
- Generated project collection now includes required STAC extensions (`osc`, `themes`, `contacts`) and OSC-mandatory fields (`osc:type`, `osc:status`, `themes`, `contacts`) to pass OSC catalog validation.
- Added optional `osc_project_url` field to the dataset config; used as the `via` link in the project collection. Falls back to `documentation_link` if omitted; defaults to the existing DeepESDL project collection when neither is provided.
- `dataset_status` now defaults to `"ongoing"` when not specified in the dataset config.
- Added a new CLI command `generate-prr-collection` that writes a Project Results Repository (PRR) STAC collection as local files, ready for submission to the ESA EarthCODE PRR endpoint. It reuses the dataset config and needs no GitHub credentials or S3 write access. See [PRR collection specification](https://eoresults.esa.int/prr_collection_specifications.html).
- The PRR command produces a self-contained `Collection → Item → Assets` tree: the Item carries the `datacube` extension (`cube:dimensions` / `cube:variables` extracted from the Zarr) with `zarr-data` and `zarr-consolidated-metadata` assets, and the Collection declares the OSC, Scientific, Processing, Themes and CF extensions.
- Added PRR-specific dataset config fields: `osc_initiative` (default `earthcode`), `osc_missions`, `osc_contract_number`, `osc_project_website`, `osc_project_description`, `thumbnail`, `thumbnail_media_type`, `sci_doi`, `sci_citation`, and `prr_output_dir`. Missing PRR-required fields produce a warning rather than a failure.
- `generate-config` templates now document the PRR fields under a dedicated section.
2 changes: 2 additions & 0 deletions deep_code/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from deep_code.cli.generate_config import generate_config
from deep_code.cli.lint import lint_dataset
from deep_code.cli.prr import generate_prr_collection_cmd
from deep_code.cli.publish import publish


Expand All @@ -20,6 +21,7 @@ def main():
main.add_command(publish)
main.add_command(generate_config)
main.add_command(lint_dataset)
main.add_command(generate_prr_collection_cmd)

if __name__ == "__main__":
main()
7 changes: 7 additions & 0 deletions deep_code/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,10 @@
"https://stac-extensions.github.io/application/v0.1.0/schema.json"
)
ZARR_MEDIA_TYPE = "application/vnd+zarr"
DATACUBE_SCHEMA_URI = "https://stac-extensions.github.io/datacube/v2.2.0/schema.json"
PROCESSING_SCHEMA_URI = (
"https://stac-extensions.github.io/processing/v1.2.0/schema.json"
)
SCIENTIFIC_SCHEMA_URI = (
"https://stac-extensions.github.io/scientific/v1.0.0/schema.json"
)
290 changes: 289 additions & 1 deletion deep_code/tests/utils/test_dataset_stac_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@
# Permissions are hereby granted under the terms of the MIT License:
# https://opensource.org/licenses/MIT.

import json
import os
import tempfile
import unittest
from datetime import datetime
from unittest.mock import MagicMock, patch

import numpy as np
from pystac import Catalog, Item
from pystac import Catalog, Collection, Item
from xarray import DataArray, Dataset

from deep_code.constants import (
DATACUBE_SCHEMA_URI,
DEEPESDL_COLLECTION_SELF_HREF,
OSC_SCHEMA_URI,
OSC_THEME_SCHEME,
PROCESSING_SCHEMA_URI,
PRODUCT_BASE_CATALOG_SELF_HREF,
VARIABLE_BASE_CATALOG_SELF_HREF,
ZARR_MEDIA_TYPE,
Expand Down Expand Up @@ -763,3 +769,285 @@ def test_update_existing_variable_catalog(self, mock_open_ds):
rels = [lnk["rel"] for lnk in result["links"]]
self.assertIn("child", rels)
self.assertIn("related", rels) # theme link


class TestPRRCollection(unittest.TestCase):
"""Tests for the PRR-style Collection -> Item -> Assets generation."""

@patch("deep_code.utils.dataset_stac_generator.open_dataset")
def setUp(self, mock_open_ds):
self.dataset = Dataset(
coords={
"lon": ("lon", np.linspace(-20, 20, 4)),
"lat": ("lat", np.linspace(-10, 10, 3)),
"time": (
"time",
[
np.datetime64(datetime(2021, 1, 1), "ns"),
np.datetime64(datetime(2021, 1, 3), "ns"),
],
),
},
data_vars={
"sst": (
("time", "lat", "lon"),
np.random.rand(2, 3, 4),
{"units": "K", "long_name": "Sea surface temperature"},
),
"chl": (
("time", "lat", "lon"),
np.random.rand(2, 3, 4),
{"units": "mg m-3"},
),
# A CRS var that must be excluded from cube:variables and drive EPSG.
"spatial_ref": ((), 0, {"spatial_epsg": 3035}),
},
attrs={"description": "PRR test cube"},
)
mock_open_ds.return_value = self.dataset
self.gen = OscDatasetStacGenerator(
dataset_id="test.zarr",
collection_id="prr-collection",
workflow_id="wf",
workflow_title="WF",
license_type="CC-BY-4.0",
access_link="s3://bucket/test.zarr",
osc_status="ongoing",
osc_region="Global",
osc_themes=["oceans"],
osc_missions=["sentinel-3"],
documentation_link="https://example.org/doc",
)

# ---- helpers ----

def test_get_epsg_from_spatial_ref(self):
self.assertEqual(self.gen._get_epsg(), 3035)

@patch("deep_code.utils.dataset_stac_generator.open_dataset")
def test_get_epsg_default_4326(self, mock_open_ds):
ds = Dataset(
coords={
"lon": ("lon", np.linspace(-1, 1, 2)),
"lat": ("lat", np.linspace(-1, 1, 2)),
"time": ("time", [np.datetime64(datetime(2020, 1, 1), "ns")]),
},
data_vars={"v": (("time", "lat", "lon"), np.random.rand(1, 2, 2))},
)
mock_open_ds.return_value = ds
gen = OscDatasetStacGenerator(
dataset_id="t.zarr",
collection_id="c",
workflow_id="wf",
workflow_title="WF",
license_type="CC-BY-4.0",
)
self.assertEqual(gen._get_epsg(), 4326)

def test_get_cube_dimensions(self):
dims = self.gen._get_cube_dimensions()
self.assertEqual(set(dims), {"lon", "lat", "time"})
self.assertEqual(dims["lon"]["type"], "spatial")
self.assertEqual(dims["lon"]["axis"], "x")
self.assertEqual(dims["lon"]["reference_system"], 3035)
self.assertEqual(dims["lon"]["extent"], [-20.0, 20.0])
self.assertEqual(dims["lat"]["axis"], "y")
self.assertEqual(dims["lat"]["extent"], [-10.0, 10.0])
self.assertEqual(dims["time"]["type"], "temporal")
self.assertEqual(len(dims["time"]["extent"]), 2)

def test_get_cube_variables(self):
variables = self.gen._get_cube_variables()
# CRS variable must be excluded.
self.assertEqual(set(variables), {"sst", "chl"})
self.assertEqual(variables["sst"]["type"], "data")
self.assertEqual(variables["sst"]["dimensions"], ["time", "lat", "lon"])
self.assertEqual(variables["sst"]["unit"], "K")
self.assertEqual(variables["sst"]["description"], "Sea surface temperature")
# chl has a unit but no long_name/description.
self.assertEqual(variables["chl"]["unit"], "mg m-3")
self.assertNotIn("description", variables["chl"])

# ---- item ----

def test_build_prr_stac_item(self):
item = self.gen.build_prr_stac_item()
self.assertIsInstance(item, Item)
self.assertEqual(item.id, "prr-collection")
self.assertIn(DATACUBE_SCHEMA_URI, item.stac_extensions)
self.assertIn("cube:dimensions", item.properties)
self.assertIn("cube:variables", item.properties)

# datetime is null; start/end are timezone-aware ISO strings.
self.assertIsNone(item.datetime)
self.assertTrue(item.properties["start_datetime"].endswith("+00:00"))
self.assertTrue(item.properties["end_datetime"].endswith("+00:00"))

self.assertEqual(set(item.assets), {"zarr-data", "zarr-consolidated-metadata"})
self.assertEqual(item.assets["zarr-data"].href, "s3://bucket/test.zarr")
self.assertEqual(item.assets["zarr-data"].media_type, ZARR_MEDIA_TYPE)
self.assertEqual(
item.assets["zarr-consolidated-metadata"].href,
"s3://bucket/test.zarr/.zmetadata",
)

# ---- collection ----

def test_build_prr_collection(self):
coll = self.gen.build_prr_collection()
self.assertIsInstance(coll, Collection)
self.assertEqual(coll.id, "prr-collection")
self.assertEqual(coll.license, "CC-BY-4.0")

ef = coll.extra_fields
self.assertEqual(ef["osc:type"], "product")
self.assertEqual(ef["osc:status"], "ongoing")
self.assertEqual(ef["osc:region"], "Global")
self.assertCountEqual(ef["osc:variables"], ["sst", "chl"])
self.assertEqual(ef["osc:missions"], ["sentinel-3"])
self.assertEqual(ef["cf:parameter"], [{"name": "prr-collection"}])
self.assertIn("processing:datetime", ef)

# Extensions declared.
self.assertIn(OSC_SCHEMA_URI, coll.stac_extensions)
self.assertIn(PROCESSING_SCHEMA_URI, coll.stac_extensions)

# Themes are plain dicts (not Theme objects).
themes = ef["themes"]
self.assertEqual(themes[0]["scheme"], OSC_THEME_SCHEME)
self.assertEqual(themes[0]["concepts"], [{"id": "oceans"}])
self.assertIsInstance(themes[0], dict)

# The single Item is attached as a child.
items = list(coll.get_items())
self.assertEqual(len(items), 1)
self.assertEqual(items[0].id, "prr-collection")

def test_build_prr_collection_conformant_fields(self):
"""A fully configured generator emits every PRR-required field."""
from deep_code.constants import SCIENTIFIC_SCHEMA_URI

with patch(
"deep_code.utils.dataset_stac_generator.open_dataset",
return_value=self.dataset,
):
gen = OscDatasetStacGenerator(
dataset_id="test.zarr",
collection_id="prr-collection",
workflow_id="wf",
workflow_title="WF",
license_type="CC-BY-4.0",
access_link="s3://bucket/test.zarr",
osc_status="ongoing",
osc_region="Global",
osc_themes=["oceans"],
osc_missions=["sentinel-3"],
osc_project_description="A detailed project description.",
osc_project_website="https://project.example.org",
osc_contract_number="4000114410/15/NL/BW",
thumbnail="https://example.org/logo.jpeg",
sci_doi="10.1000/xyz123",
)
coll = gen.build_prr_collection()
ef = coll.extra_fields

# Scientific extension declared alongside the others.
self.assertIn(SCIENTIFIC_SCHEMA_URI, coll.stac_extensions)
# Required PRR project fields present.
self.assertEqual(ef["osc:initiative"], "earthcode")
self.assertEqual(ef["osc:project_website"], "https://project.example.org")
self.assertEqual(
ef["osc:project_description"], "A detailed project description."
)
self.assertEqual(ef["osc:contract-number"], "4000114410/15/NL/BW")
self.assertEqual(ef["sci:doi"], "10.1000/xyz123")
# Thumbnail asset with correct role and guessed media type.
self.assertIn("thumbnail", coll.assets)
thumb = coll.assets["thumbnail"]
self.assertEqual(thumb.roles, ["thumbnail"])
self.assertEqual(thumb.media_type, "image/jpeg")

def test_build_prr_collection_fallbacks(self):
"""project_website/description fall back to doc link / description."""
coll = self.gen.build_prr_collection()
ef = coll.extra_fields
# Default initiative.
self.assertEqual(ef["osc:initiative"], "earthcode")
# Fallbacks: website -> documentation_link, description -> dataset description.
self.assertEqual(ef["osc:project_website"], "https://example.org/doc")
self.assertEqual(ef["osc:project_description"], "PRR test cube")
# No thumbnail / contract number configured -> absent.
self.assertNotIn("thumbnail", coll.assets)
self.assertNotIn("osc:contract-number", ef)

def test_thumbnail_media_type_guess(self):
self.gen.thumbnail = "https://x/logo.png"
self.assertEqual(self.gen._thumbnail_media_type(), "image/png")
self.gen.thumbnail = "https://x/logo.JPG"
self.assertEqual(self.gen._thumbnail_media_type(), "image/jpeg")
self.gen.thumbnail = "https://x/logo.webp"
self.assertEqual(self.gen._thumbnail_media_type(), "image/webp")
self.gen.thumbnail_media_type = "image/tiff"
self.assertEqual(self.gen._thumbnail_media_type(), "image/tiff")

def test_build_prr_collection_cf_params_override(self):
self.gen.cf_params = [{"name": "sst", "units": "K"}]
coll = self.gen.build_prr_collection()
self.assertEqual(
coll.extra_fields["cf:parameter"], [{"name": "sst", "units": "K"}]
)

@patch("deep_code.utils.dataset_stac_generator.open_dataset")
def test_build_prr_collection_no_themes(self, mock_open_ds):
mock_open_ds.return_value = self.dataset
gen = OscDatasetStacGenerator(
dataset_id="test.zarr",
collection_id="prr-collection",
workflow_id="wf",
workflow_title="WF",
license_type="CC-BY-4.0",
access_link="s3://bucket/test.zarr",
)
coll = gen.build_prr_collection()
self.assertNotIn("themes", coll.extra_fields)

# ---- save (local self-contained tree) ----

def test_save_prr_collection_writes_tree(self):
with tempfile.TemporaryDirectory() as tmp:
out = self.gen.save_prr_collection(tmp)
self.assertEqual(out, tmp)

collection_path = os.path.join(tmp, "collection.json")
item_path = os.path.join(tmp, "prr-collection", "prr-collection.json")
self.assertTrue(os.path.isfile(collection_path))
self.assertTrue(os.path.isfile(item_path))

# Files are plain-JSON serialisable (no leftover Python objects).
with open(collection_path) as f:
coll_dict = json.load(f)
with open(item_path) as f:
item_dict = json.load(f)

self.assertEqual(coll_dict["type"], "Collection")
self.assertEqual(item_dict["type"], "Feature")

# Structural links are relative; the Item link points at the child.
item_link = next(
lnk for lnk in coll_dict["links"] if lnk["rel"] == "item"
)
self.assertFalse(item_link["href"].startswith("s3://"))
self.assertTrue(item_link["href"].endswith(".json"))

# Asset hrefs stay absolute (the data lives on S3).
self.assertEqual(
item_dict["assets"]["zarr-data"]["href"], "s3://bucket/test.zarr"
)

def test_save_prr_collection_readable_by_pystac(self):
with tempfile.TemporaryDirectory() as tmp:
self.gen.save_prr_collection(tmp)
coll = Collection.from_file(os.path.join(tmp, "collection.json"))
items = list(coll.get_items())
self.assertEqual(len(items), 1)
self.assertIn(DATACUBE_SCHEMA_URI, items[0].stac_extensions)
20 changes: 20 additions & 0 deletions deep_code/tools/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ def generate_dataset_template(output_path: Optional[str] = None) -> str:
"cf_parameter": [{"name": "[OPTIONAL: CF standard name]", "units": "[unit string]"}],
}

# Fields used only by `deep-code generate-prr-collection` to build a
# PRR (Project Results Repository) collection that conforms to
# https://eoresults.esa.int/prr_collection_specifications.html
prr = {
"prr_output_dir": "[OPTIONAL: local dir for the PRR collection tree — defaults to prr/{collection_id}]",
"osc_initiative": "[OPTIONAL: PRR initiative — 'earthcode' or 'apex' (default: earthcode)]",
"osc_contract_number": "[PRR-REQUIRED: ESA contract identifier, e.g. 4000114410/15/NL/BW]",
"osc_project_website": "[PRR-REQUIRED: project website URL — falls back to osc_project_url / documentation_link]",
"osc_project_description": "[PRR-REQUIRED: multi-line project description — falls back to description]",
"osc_missions": ["[PRR-REQUIRED: satellite mission name(s), e.g. sentinel-3]"],
"thumbnail": "[PRR-REQUIRED: URL to a collection thumbnail image (jpeg/png/webp)]",
"thumbnail_media_type": "[OPTIONAL: thumbnail MIME type — guessed from the URL suffix if omitted]",
"sci_doi": "[OPTIONAL: dataset DOI, e.g. 10.1000/xyz123 (not a DOI link)]",
"sci_citation": "[OPTIONAL: human-readable citation for the dataset]",
}

stac_catalog_comment = (
"\n# stac_catalog_s3_root: deep-code writes the following files to this S3 root:\n"
"# {stac_catalog_s3_root}/catalog.json (STAC Catalog root)\n"
Expand All @@ -94,4 +110,8 @@ def generate_dataset_template(output_path: Optional[str] = None) -> str:
f.write(yaml.dump(required, sort_keys=False, width=1000, default_flow_style=False))
f.write("\n# --- OPTIONAL fields ---\n")
f.write(yaml.dump(optional, sort_keys=False, width=1000, default_flow_style=False))
f.write(
"\n# --- PRR fields (for `deep-code generate-prr-collection`) ---\n"
)
f.write(yaml.dump(prr, sort_keys=False, width=1000, default_flow_style=False))
f.write(stac_catalog_comment)
Loading
Loading