From 3cc0c5384ce58a1169fe39a967c44e2eb9b36656 Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Fri, 13 Nov 2026 14:35:53 -0500 Subject: [PATCH 1/3] feat: metadata validation check --- src/hls-metadata-validation/README.md | 38 ++ src/hls-metadata-validation/pyproject.toml | 61 +++ .../src/hls_metadata_validation/__init__.py | 1 + .../src/hls_metadata_validation/cli.py | 58 +++ .../src/hls_metadata_validation/cmr.py | 130 +++++ .../src/hls_metadata_validation/cog.py | 118 +++++ .../src/hls_metadata_validation/discover.py | 66 +++ .../src/hls_metadata_validation/findings.py | 69 +++ .../src/hls_metadata_validation/golden.py | 241 +++++++++ .../src/hls_metadata_validation/runner.py | 154 ++++++ .../src/hls_metadata_validation/spec.py | 193 +++++++ .../hls_metadata_validation/specs/L30.json | 119 +++++ .../hls_metadata_validation/specs/S30.json | 203 ++++++++ .../specs/common/angles.json | 18 + .../specs/common/attributes.json | 209 ++++++++ .../specs/common/encodings/angle.json | 7 + .../specs/common/encodings/fmask.json | 6 + .../specs/common/encodings/int16_fill.json | 4 + .../specs/common/encodings/reflectance.json | 5 + .../specs/common/encodings/thermal.json | 5 + .../specs/common/fmask.json | 6 + .../specs/spec.schema.json | 178 +++++++ .../src/hls_metadata_validation/stac.py | 96 ++++ .../src/hls_metadata_validation/valuetypes.py | 25 + src/hls-metadata-validation/tests/conftest.py | 36 ++ ...30.T56NQK.2026309T235813.v2.0.B01.tif.json | 68 +++ ...30.T56NQK.2026309T235813.v2.0.B02.tif.json | 68 +++ ...30.T56NQK.2026309T235813.v2.0.B03.tif.json | 68 +++ ...30.T56NQK.2026309T235813.v2.0.B04.tif.json | 68 +++ ...30.T56NQK.2026309T235813.v2.0.B05.tif.json | 68 +++ ...30.T56NQK.2026309T235813.v2.0.B06.tif.json | 68 +++ ...30.T56NQK.2026309T235813.v2.0.B07.tif.json | 68 +++ ...30.T56NQK.2026309T235813.v2.0.B09.tif.json | 68 +++ ...30.T56NQK.2026309T235813.v2.0.B10.tif.json | 69 +++ ...30.T56NQK.2026309T235813.v2.0.B11.tif.json | 69 +++ ....T56NQK.2026309T235813.v2.0.Fmask.tif.json | 66 +++ ...30.T56NQK.2026309T235813.v2.0.SAA.tif.json | 34 ++ ...30.T56NQK.2026309T235813.v2.0.SZA.tif.json | 34 ++ ...30.T56NQK.2026309T235813.v2.0.VAA.tif.json | 34 ++ ...30.T56NQK.2026309T235813.v2.0.VZA.tif.json | 34 ++ ...HLS.L30.T56NQK.2026309T235813.v2.0.cmr.xml | 406 +++++++++++++++ .../HLS.L30.T56NQK.2026309T235813.v2.0.jpg | 0 ...S.L30.T56NQK.2026309T235813.v2.0_stac.json | 1 + .../tests/data/README.md | 27 + ...30.T55GEM.2026309T235229.v2.0.B01.tif.json | 74 +++ ...30.T55GEM.2026309T235229.v2.0.B02.tif.json | 74 +++ ...30.T55GEM.2026309T235229.v2.0.B03.tif.json | 74 +++ ...30.T55GEM.2026309T235229.v2.0.B04.tif.json | 74 +++ ...30.T55GEM.2026309T235229.v2.0.B05.tif.json | 74 +++ ...30.T55GEM.2026309T235229.v2.0.B06.tif.json | 74 +++ ...30.T55GEM.2026309T235229.v2.0.B07.tif.json | 74 +++ ...30.T55GEM.2026309T235229.v2.0.B08.tif.json | 74 +++ ...30.T55GEM.2026309T235229.v2.0.B09.tif.json | 74 +++ ...30.T55GEM.2026309T235229.v2.0.B10.tif.json | 74 +++ ...30.T55GEM.2026309T235229.v2.0.B11.tif.json | 74 +++ ...30.T55GEM.2026309T235229.v2.0.B12.tif.json | 74 +++ ...30.T55GEM.2026309T235229.v2.0.B8A.tif.json | 74 +++ ....T55GEM.2026309T235229.v2.0.Fmask.tif.json | 72 +++ ...30.T55GEM.2026309T235229.v2.0.SAA.tif.json | 39 ++ ...30.T55GEM.2026309T235229.v2.0.SZA.tif.json | 39 ++ ...30.T55GEM.2026309T235229.v2.0.VAA.tif.json | 39 ++ ...30.T55GEM.2026309T235229.v2.0.VZA.tif.json | 39 ++ ...HLS.S30.T55GEM.2026309T235229.v2.0.cmr.xml | 488 ++++++++++++++++++ .../HLS.S30.T55GEM.2026309T235229.v2.0.jpg | 0 ...S.S30.T55GEM.2026309T235229.v2.0_stac.json | 1 + src/hls-metadata-validation/tests/fixtures.py | 25 + src/hls-metadata-validation/tests/test_cmr.py | 23 + .../tests/test_golden.py | 132 +++++ .../tests/test_product_specs.py | 13 + .../tests/test_runner.py | 48 ++ .../tests/test_spec.py | 65 +++ .../tests/test_stac.py | 21 + 72 files changed, 5370 insertions(+) create mode 100644 src/hls-metadata-validation/README.md create mode 100644 src/hls-metadata-validation/pyproject.toml create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/__init__.py create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/cli.py create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/cmr.py create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/cog.py create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/discover.py create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/findings.py create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/golden.py create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/runner.py create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/spec.py create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/specs/L30.json create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/specs/S30.json create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/specs/common/angles.json create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/specs/common/attributes.json create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/angle.json create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/fmask.json create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/int16_fill.json create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/reflectance.json create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/thermal.json create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/specs/common/fmask.json create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/specs/spec.schema.json create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/stac.py create mode 100644 src/hls-metadata-validation/src/hls_metadata_validation/valuetypes.py create mode 100644 src/hls-metadata-validation/tests/conftest.py create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B01.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B02.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B03.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B04.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B05.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B06.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B07.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B09.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B10.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B11.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.Fmask.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.SAA.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.SZA.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.VAA.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.VZA.tif.json create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.cmr.xml create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.jpg create mode 100644 src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0_stac.json create mode 100644 src/hls-metadata-validation/tests/data/README.md create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B01.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B02.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B03.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B04.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B05.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B06.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B07.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B08.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B09.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B10.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B11.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B12.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B8A.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.Fmask.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.SAA.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.SZA.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.VAA.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.VZA.tif.json create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.cmr.xml create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.jpg create mode 100644 src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0_stac.json create mode 100644 src/hls-metadata-validation/tests/fixtures.py create mode 100644 src/hls-metadata-validation/tests/test_cmr.py create mode 100644 src/hls-metadata-validation/tests/test_golden.py create mode 100644 src/hls-metadata-validation/tests/test_product_specs.py create mode 100644 src/hls-metadata-validation/tests/test_runner.py create mode 100644 src/hls-metadata-validation/tests/test_spec.py create mode 100644 src/hls-metadata-validation/tests/test_stac.py diff --git a/src/hls-metadata-validation/README.md b/src/hls-metadata-validation/README.md new file mode 100644 index 00000000..dde1201b --- /dev/null +++ b/src/hls-metadata-validation/README.md @@ -0,0 +1,38 @@ +# hls-metadata-validation + +Validates the metadata of final HLS products (COG GeoTIFF tags, CMR XML, STAC +Item JSON) against a data-driven JSON spec, and compares freshly produced +outputs against reference production outputs. + +Design: `docs/superpowers/specs/2026-11-12-hls-metadata-validation-design.md`. + +## Specs + +Product specs live in `src/hls_metadata_validation/specs/` (`L30.json`, +`S30.json`, shared fragments under `common/`). A JSON object of the form +`{"$include": ""}` or `{"$include": ["", ...], ...}` is replaced by +the merged contents of the referenced files (relative to the including file); +duplicate keys are an error so each fact lives in one place. + +Golden comparison: `default_tolerance` is a relative tolerance applied to every +numeric value; an attribute's `tolerance` is an additional absolute tolerance +(e.g. `1.0` percentage point for `CLOUD_COVERAGE`). + +## Usage + +```bash +# Validate final outputs (COGs, .cmr.xml, _stac.json, .jpg) in a directory tree +hls-validate-metadata spec PRODUCT_DIR [--product L30|S30] [--json report.json] + +# Write normalized COG metadata (.tif.json) plus CMR/STAC/thumbnail copies, +# e.g. to create small test fixtures; `spec` accepts the dumped directory too +hls-validate-metadata dump PRODUCT_DIR OUT_DIR +``` + +Exit status is 0 when there are no errors; known gaps are reported as warnings. + +```bash +# Validate NEW_DIR, then compare it to reference outputs (same layout) in REF_DIR. +# Granules with no reference are reported as skipped (warning). +hls-validate-metadata golden NEW_DIR REF_DIR [--json report.json] +``` diff --git a/src/hls-metadata-validation/pyproject.toml b/src/hls-metadata-validation/pyproject.toml new file mode 100644 index 00000000..4b208972 --- /dev/null +++ b/src/hls-metadata-validation/pyproject.toml @@ -0,0 +1,61 @@ +[project] +name = "hls-metadata-validation" +version = "0.1.0" +description = "Validate HLS product metadata (COG tags, CMR XML, STAC) against a data-driven spec" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "click>=8.1", + "jsonschema>=4.20", + "lxml", + "metadata-creator @ git+https://github.com/NASA-IMPACT/hls-metadata@v2.9", + "pystac[validation]>=1.10,<1.12", + "rasterio", + "rio-cogeo", +] + +[project.scripts] +hls-validate-metadata = "hls_metadata_validation.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["src/hls_metadata_validation"] + +[project.optional-dependencies] +dev = [ + "lxml-stubs>=0.5.1", + "mypy>=1.14.0", + "pytest>=9.0.2", + "ruff>=0.9.0", + "types-jsonschema", +] + +[dependency-groups] +dev = [ + "hls-metadata-validation[dev]", +] + +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = ["F", "E", "W", "I", "UP"] +ignore = ["E501", "E731"] + +[tool.ruff.lint.isort] +known-first-party = ["hls_metadata_validation"] + +[tool.mypy] +python_version = "3.13" +strict = true + +[[tool.mypy.overrides]] +module = ["metadata_creator", "metadata_creator.*", "rasterio", "rasterio.*", "rio_cogeo", "rio_cogeo.*"] +ignore_missing_imports = true diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/__init__.py b/src/hls-metadata-validation/src/hls_metadata_validation/__init__.py new file mode 100644 index 00000000..c45fa792 --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/__init__.py @@ -0,0 +1 @@ +"""Validate HLS product metadata against a data-driven spec.""" diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/cli.py b/src/hls-metadata-validation/src/hls_metadata_validation/cli.py new file mode 100644 index 00000000..d6dddd8a --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/cli.py @@ -0,0 +1,58 @@ +"""Command line interface: ``hls-validate-metadata``.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import click + +from hls_metadata_validation.findings import Report +from hls_metadata_validation.golden import golden_dir +from hls_metadata_validation.runner import dump_dir, validate_dir + +PRODUCTS = click.Choice(["L30", "S30"]) +DIR = click.Path(exists=True, file_okay=False, path_type=Path) + + +def _finish(report: Report, json_path: Path | None) -> None: + click.echo(report.to_text()) + if json_path is not None: + json_path.write_text(json.dumps(report.to_dict(), indent=2) + "\n") + raise SystemExit(0 if report.ok else 1) + + +@click.group() +def main() -> None: + """Validate HLS product metadata against the packaged specs.""" + + +@main.command() +@click.argument("product_dir", type=DIR) +@click.argument("out_dir", type=click.Path(file_okay=False, path_type=Path)) +def dump(product_dir: Path, out_dir: Path) -> None: + """Write normalized metadata of PRODUCT_DIR's outputs into OUT_DIR.""" + written = dump_dir(product_dir, out_dir) + click.echo(f"wrote {len(written)} file(s) to {out_dir}") + + +@main.command() +@click.argument("product_dir", type=DIR) +@click.option("--product", type=PRODUCTS, default=None, help="Override product type.") +@click.option( + "--json", "json_path", type=click.Path(dir_okay=False, path_type=Path), default=None +) +def spec(product_dir: Path, product: str | None, json_path: Path | None) -> None: + """Validate outputs in PRODUCT_DIR against the product spec.""" + _finish(validate_dir(product_dir, product), json_path) + + +@main.command() +@click.argument("new_dir", type=DIR) +@click.argument("ref_dir", type=DIR) +@click.option( + "--json", "json_path", type=click.Path(dir_okay=False, path_type=Path), default=None +) +def golden(new_dir: Path, ref_dir: Path, json_path: Path | None) -> None: + """Validate NEW_DIR, then compare it against reference outputs in REF_DIR.""" + _finish(golden_dir(new_dir, ref_dir), json_path) diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/cmr.py b/src/hls-metadata-validation/src/hls_metadata_validation/cmr.py new file mode 100644 index 00000000..a1414b6e --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/cmr.py @@ -0,0 +1,130 @@ +"""Normalize and check CMR ECHO10 granule XML.""" + +from __future__ import annotations + +import math +from functools import cache +from importlib.resources import files +from pathlib import Path +from typing import Any + +from lxml import etree + +from hls_metadata_validation.findings import Finding +from hls_metadata_validation.spec import ProductSpec +from hls_metadata_validation.valuetypes import matches_type + + +@cache +def _schema() -> etree.XMLSchema: + xsd = Path(str(files("metadata_creator") / "templates" / "Granule.xsd")) + return etree.XMLSchema(etree.parse(str(xsd))) + + +def _element_to_obj(el: etree._Element) -> Any: + children = [c for c in el if isinstance(c.tag, str)] + if not children: + return (el.text or "").strip() + out: dict[str, Any] = {} + for child in children: + value = _element_to_obj(child) + tag = str(child.tag) + if tag in out: + if not isinstance(out[tag], list): + out[tag] = [out[tag]] + out[tag].append(value) + else: + out[tag] = value + return out + + +def normalize_cmr(path: Path) -> dict[str, Any]: + try: + tree = etree.parse(str(path)) + except (OSError, etree.XMLSyntaxError) as e: + return { + "granule_ur": None, + "xsd_errors": [f"cannot parse XML: {e}"], + "additional_attributes": {}, + "granule": {}, + } + root = tree.getroot() + schema = _schema() + schema.validate(tree) + attributes: dict[str, list[str]] = {} + for attr in root.findall("./AdditionalAttributes/AdditionalAttribute"): + name = (attr.findtext("Name") or "").strip() + values = [ + (v.text or "").strip() + for v in attr.findall("./Values/Value") + if (v.text or "").strip() + ] + attributes[name] = values + granule = _element_to_obj(root) + if isinstance(granule, dict): + granule.pop("AdditionalAttributes", None) + else: + granule = {} + return { + "granule_ur": root.findtext("GranuleUR"), + "xsd_errors": [ + f"line {e.line}: {e.message}" + for e in schema.error_log # type: ignore[attr-defined] + ], + "additional_attributes": attributes, + "granule": granule, + } + + +def resolve_value_from(spec: ProductSpec, ref: str) -> float | int | str | None: + """Resolve a ``"."`` reference against the spec's assets.""" + asset_name, field = ref.split(".", 1) + value = getattr(spec.assets[asset_name], field) + if value is None or isinstance(value, (int, float, str)): + return value + raise TypeError(f"{ref} resolves to unsupported type {type(value).__name__}") + + +def check_cmr(doc: dict[str, Any], spec: ProductSpec, *, granule: str) -> list[Finding]: + def err(check: str, target: str, message: str) -> Finding: + return Finding("error", "cmr", check, granule, target, message) + + findings = [err("xsd", "Granule.xsd", e) for e in doc.get("xsd_errors", [])] + present: dict[str, list[str]] = doc.get("additional_attributes", {}) + for attr in spec.attributes.values(): + name = attr.cmr_additional_attribute + if name is None: + continue + values = present.get(name, []) + if not values: + if attr.required: + findings.append( + err("attr.required", name, "required AdditionalAttribute missing") + ) + continue + bad = [v for v in values if not matches_type(v, attr.type)] + if bad: + findings.append( + err("attr.type", name, f"value(s) {bad!r} are not of type {attr.type}") + ) + continue + if attr.value_from is None: + continue + expected = resolve_value_from(spec, attr.value_from) + if expected is None: + continue + try: + matches = math.isclose( + float(values[0]), float(expected), rel_tol=1e-9, abs_tol=1e-12 + ) + except ValueError: + matches = False + if not matches: + findings.append( + err( + "attr.value", + name, + f"value {values[0]!r}, expected {expected} ({attr.value_from})", + ) + ) + return findings diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/cog.py b/src/hls-metadata-validation/src/hls_metadata_validation/cog.py new file mode 100644 index 00000000..6b9a61e9 --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/cog.py @@ -0,0 +1,118 @@ +"""Normalize and check COG metadata.""" + +from __future__ import annotations + +import math +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +import rasterio +from rio_cogeo.cogeo import cog_validate + +from hls_metadata_validation.findings import Finding +from hls_metadata_validation.spec import AssetSpec, AttributeType +from hls_metadata_validation.valuetypes import matches_type + +COG_DOC_KEYS = ( + "tags", + "band_tags", + "nodata", + "scales", + "offsets", + "dtype", + "count", + "descriptions", + "crs", + "transform", + "width", + "height", + "is_cog", +) + + +def normalize_cog(path: Path) -> dict[str, Any]: + """Read the metadata of a COG into a JSON-serializable dict.""" + is_cog, _errors, _warnings = cog_validate(str(path), quiet=True) + with rasterio.open(path) as src: + return { + "tags": dict(src.tags()), + "band_tags": [dict(src.tags(i)) for i in src.indexes], + "nodata": src.nodata, + "scales": [float(s) for s in src.scales], + "offsets": [float(o) for o in src.offsets], + "dtype": src.dtypes[0], + "count": src.count, + "descriptions": list(src.descriptions), + "crs": src.crs.to_string() if src.crs else None, + "transform": [float(v) for v in list(src.transform)[:6]], + "width": src.width, + "height": src.height, + "is_cog": bool(is_cog), + } + + +def _close(a: float, b: float) -> bool: + return math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12) + + +def check_cog( + doc: dict[str, Any], + asset: AssetSpec, + *, + granule: str, + required_tags: Iterable[str], + tag_types: Mapping[str, AttributeType] | None = None, +) -> list[Finding]: + """Check a normalized COG doc against the asset spec.""" + + def err(check: str, message: str, target: str = asset.name) -> Finding: + return Finding("error", "cog", check, granule, target, message) + + missing = [k for k in COG_DOC_KEYS if k not in doc] + if missing: + return [err("doc.key", f"normalized COG doc lacks {k!r}") for k in missing] + + findings: list[Finding] = [] + if doc["dtype"] != asset.dtype: + findings.append(err("dtype", f"dtype {doc['dtype']}, expected {asset.dtype}")) + + nodata = doc["nodata"] + if nodata is None: + findings.append(err("nodata", f"nodata not set, expected {asset.nodata}")) + elif not _close(float(nodata), float(asset.nodata)): + findings.append(err("nodata", f"nodata {nodata}, expected {asset.nodata}")) + + for check, values, expected, default in ( + ("scale", doc["scales"], asset.scale_factor, 1.0), + ("offset", doc["offsets"], asset.add_offset, 0.0), + ): + want = default if expected is None else expected + if not values: + findings.append(err(check, f"no {check} reported, expected {want}")) + elif not _close(float(values[0]), want): + findings.append(err(check, f"{check} {values[0]}, expected {want}")) + + if not doc["is_cog"]: + findings.append(err("is_cog", "file is not a valid Cloud Optimized GeoTIFF")) + + tags = doc["tags"] + for tag in required_tags: + if tag not in tags or str(tags[tag]).strip() == "": + findings.append( + err( + "tag.required", + f"required tag {tag!r} missing", + f"{asset.name}:{tag}", + ) + ) + for tag, type_ in (tag_types or {}).items(): + if tag in tags and not matches_type(str(tags[tag]), type_): + findings.append( + err( + "tag.type", + f"tag value {tags[tag]!r} is not of type {type_}", + f"{asset.name}:{tag}", + ) + ) + return findings diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/discover.py b/src/hls-metadata-validation/src/hls_metadata_validation/discover.py new file mode 100644 index 00000000..ec3c920e --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/discover.py @@ -0,0 +1,66 @@ +"""Find HLS granule output files in a directory tree.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, cast + +from hls_metadata_validation.cog import normalize_cog + +GRANULE_RE = re.compile( + r"^(?PHLS\.(?P[LS]30)\.T\w{5}\.\d{7}T\d{6}\.v\d+\.\d+)" + r"(?P[._].+)$" +) +COG_DUMP_SUFFIX = ".tif.json" + + +@dataclass +class GranuleFiles: + granule: str + product: str + cogs: dict[str, Path] = field(default_factory=dict) + cmr: Path | None = None + stac: Path | None = None + thumbnail: Path | None = None + + +def _classify(g: GranuleFiles, rest: str, path: Path) -> None: + if rest == ".cmr.xml": + g.cmr = path + elif rest == "_stac.json": + g.stac = path + elif rest == ".jpg": + g.thumbnail = path + elif rest.startswith(".") and rest.endswith(COG_DUMP_SUFFIX): + asset = rest[1 : -len(COG_DUMP_SUFFIX)] + g.cogs.setdefault(asset, path) + elif rest.startswith(".") and rest.endswith(".tif"): + g.cogs[rest[1:-4]] = path + + +def discover(product_dir: Path) -> list[GranuleFiles]: + """Group HLS output files under ``product_dir`` by granule.""" + granules: dict[str, GranuleFiles] = {} + paths = sorted( + (p for p in Path(product_dir).rglob("*") if p.is_file()), + key=lambda p: p.name.endswith(COG_DUMP_SUFFIX), + ) + for path in paths: + m = GRANULE_RE.match(path.name) + if m is None: + continue + g = granules.setdefault( + m["granule"], GranuleFiles(granule=m["granule"], product=m["product"]) + ) + _classify(g, m["rest"], path) + return [granules[k] for k in sorted(granules)] + + +def load_cog_doc(path: Path) -> dict[str, Any]: + """Load a normalized COG doc from a ``.tif`` or a ``.tif.json`` dump.""" + if path.name.endswith(COG_DUMP_SUFFIX): + return cast(dict[str, Any], json.loads(path.read_text())) + return normalize_cog(path) diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/findings.py b/src/hls-metadata-validation/src/hls_metadata_validation/findings.py new file mode 100644 index 00000000..5d28978f --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/findings.py @@ -0,0 +1,69 @@ +"""Validation findings and reports.""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import asdict, dataclass, field, replace +from typing import Any, Literal + +from hls_metadata_validation.spec import ProductSpec + +Severity = Literal["error", "warning", "info"] +FindingSurface = Literal["cog", "cmr", "stac", "golden", "spec"] + + +@dataclass(frozen=True) +class Finding: + severity: Severity + surface: FindingSurface + check: str + granule: str + target: str + message: str + known_gap: str | None = None + + +@dataclass +class Report: + findings: list[Finding] = field(default_factory=list) + + def add(self, finding: Finding) -> None: + self.findings.append(finding) + + def extend(self, findings: list[Finding]) -> None: + self.findings.extend(findings) + + def apply_known_gaps(self, spec: ProductSpec) -> None: + """Downgrade errors matching a known gap of ``spec`` to warnings.""" + updated = [] + for f in self.findings: + if f.severity == "error": + for gap in spec.known_gaps: + if gap.matches(f.surface, f.check, f.target): + f = replace(f, severity="warning", known_gap=gap.id) + break + updated.append(f) + self.findings = updated + + @property + def ok(self) -> bool: + return not any(f.severity == "error" for f in self.findings) + + def to_dict(self) -> dict[str, Any]: + return {"ok": self.ok, "findings": [asdict(f) for f in self.findings]} + + def to_text(self) -> str: + counts = Counter(f.severity for f in self.findings) + lines = [] + for f in sorted(self.findings, key=lambda f: (f.granule, f.surface, f.target)): + gap = f" [known gap: {f.known_gap}]" if f.known_gap else "" + lines.append( + f"{f.severity.upper():7} {f.granule} {f.surface}:{f.check} " + f"{f.target}: {f.message}{gap}" + ) + status = "PASS" if self.ok else "FAIL" + lines.append( + f"{status}: {counts['error']} error(s), {counts['warning']} warning(s), " + f"{counts['info']} info" + ) + return "\n".join(lines) diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/golden.py b/src/hls-metadata-validation/src/hls_metadata_validation/golden.py new file mode 100644 index 00000000..c1dcbd5a --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/golden.py @@ -0,0 +1,241 @@ +"""Compare fresh outputs against reference (golden) outputs.""" + +from __future__ import annotations + +import math +from collections.abc import Callable +from fnmatch import fnmatchcase +from pathlib import Path +from typing import Any + +from hls_metadata_validation.cmr import normalize_cmr +from hls_metadata_validation.discover import GranuleFiles, discover, load_cog_doc +from hls_metadata_validation.findings import Finding, Report +from hls_metadata_validation.runner import safe_load, validate_dir +from hls_metadata_validation.spec import AttributeSpec, ProductSpec, load_spec +from hls_metadata_validation.stac import load_stac + +BUILTIN_VOLATILE = ( + "cmr.granule.InsertTime", + "cmr.granule.LastUpdate", + "cmr.granule.DataGranule.ProductionDateTime", + "cmr.granule.DataGranule.DataGranuleSizeInBytes", + "stac.assets.*.href", + "stac.links", + "stac.properties.created", + "stac.properties.updated", +) + + +class GoldenRules: + """Per-path comparison rules derived from a product spec.""" + + def __init__(self, spec: ProductSpec) -> None: + self.spec = spec + self._patterns: list[tuple[str, AttributeSpec]] = [] + for attr in spec.attributes.values(): + if attr.cog_tag is not None: + self._patterns.append((f"*.tags.{attr.cog_tag}", attr)) + if attr.cmr_additional_attribute is not None: + self._patterns.append( + (f"cmr.additional_attributes.{attr.cmr_additional_attribute}", attr) + ) + if attr.stac_property is not None: + self._patterns.append((f"stac.properties.{attr.stac_property}", attr)) + + def _attribute(self, path: str) -> AttributeSpec | None: + for pattern, attr in self._patterns: + if fnmatchcase(path, pattern): + return attr + return None + + def is_volatile(self, path: str) -> bool: + if any(fnmatchcase(path, p) for p in BUILTIN_VOLATILE): + return True + attr = self._attribute(path) + return attr is not None and attr.golden == "volatile" + + def tolerance_for(self, path: str) -> tuple[float, float]: + """Return ``(relative, absolute)`` tolerance for numeric values.""" + attr = self._attribute(path) + absolute = attr.tolerance if attr is not None and attr.tolerance else 0.0 + return self.spec.default_tolerance, absolute + + +def _as_float(value: Any) -> float | None: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return None + return None + + +def compare_docs( + new: Any, + ref: Any, + *, + path: str, + rules: GoldenRules, + granule: str = "", + target: str | None = None, +) -> list[Finding]: + """Recursively compare ``new`` against ``ref``.""" + tgt = path if target is None else target + + def err(check: str, message: str) -> Finding: + return Finding("error", "golden", check, granule, tgt, f"{path}: {message}") + + if rules.is_volatile(path): + return [] + + if isinstance(new, dict) and isinstance(ref, dict): + findings = [ + err("key.extra", f"key {k!r} not in reference") for k in new if k not in ref + ] + findings += [ + err("key.missing", f"key {k!r} missing") for k in ref if k not in new + ] + for key in new: + if key in ref: + findings += compare_docs( + new[key], + ref[key], + path=f"{path}.{key}", + rules=rules, + granule=granule, + target=target, + ) + return findings + + if isinstance(new, list) and isinstance(ref, list): + if len(new) != len(ref): + return [err("length", f"{len(new)} item(s), reference has {len(ref)}")] + findings = [] + for a, b in zip(new, ref, strict=True): + findings += compare_docs( + a, b, path=path, rules=rules, granule=granule, target=target + ) + return findings + + fa, fb = _as_float(new), _as_float(ref) + if fa is not None and fb is not None: + rel, absolute = rules.tolerance_for(path) + if math.isclose(fa, fb, rel_tol=rel, abs_tol=absolute): + return [] + return [err("value", f"{new!r} != reference {ref!r}")] + + if new != ref: + return [err("value", f"{new!r} != reference {ref!r}")] + return [] + + +def _compare_granule( + g: GranuleFiles, ref: GranuleFiles, rules: GoldenRules +) -> list[Finding]: + def file_missing(target: str) -> Finding: + return Finding( + "error", + "golden", + "reference.file_missing", + g.granule, + target, + "file present in new outputs but not in the reference", + ) + + findings: list[Finding] = [] + + def compare( + loader: Callable[[Path], Any], + new_path: Path, + ref_path: Path, + *, + path: str, + target: str | None, + file_target: str, + ) -> None: + docs = [] + for p in (new_path, ref_path): + doc, problem = safe_load( + loader, p, granule=g.granule, target=file_target, surface="golden" + ) + if problem is not None: + findings.append(problem) + docs.append(doc) + if docs[0] is not None and docs[1] is not None: + findings.extend( + compare_docs( + docs[0], + docs[1], + path=path, + rules=rules, + granule=g.granule, + target=target, + ) + ) + + for asset in sorted(g.cogs): + if asset not in ref.cogs: + findings.append(file_missing(asset)) + continue + compare( + load_cog_doc, + g.cogs[asset], + ref.cogs[asset], + path=asset, + target=asset, + file_target=asset, + ) + for name, new_path, ref_path, loader, prefix in ( + ("cmr.xml", g.cmr, ref.cmr, normalize_cmr, "cmr"), + ("stac.json", g.stac, ref.stac, load_stac, "stac"), + ): + if new_path is None: + continue + if ref_path is None: + findings.append(file_missing(name)) + continue + compare(loader, new_path, ref_path, path=prefix, target=None, file_target=name) + return findings + + +def golden_dir(new_dir: Path, ref_dir: Path) -> Report: + """Validate ``new_dir`` against the spec, then compare it to ``ref_dir``.""" + report = validate_dir(new_dir) + refs = {g.granule: g for g in discover(ref_dir)} + specs: dict[str, ProductSpec] = {} + for g in discover(new_dir): + spec = specs.setdefault(g.product, load_spec(g.product)) + ref = refs.get(g.granule) + if ref is None: + report.add( + Finding( + "warning", + "golden", + "reference.skipped", + g.granule, + str(ref_dir), + "no reference outputs for this granule", + ) + ) + continue + sub = Report(_compare_granule(g, ref, GoldenRules(spec))) + sub.apply_known_gaps(spec) + report.extend(sub.findings) + new_granules = {g.granule for g in discover(new_dir)} + for name in sorted(set(refs) - new_granules): + report.add( + Finding( + "error", + "golden", + "reference.unmatched", + name, + str(new_dir), + "reference granule has no counterpart in the new outputs", + ) + ) + return report diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/runner.py b/src/hls-metadata-validation/src/hls_metadata_validation/runner.py new file mode 100644 index 00000000..98322263 --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/runner.py @@ -0,0 +1,154 @@ +"""Run spec validation over HLS product directories.""" + +from __future__ import annotations + +import json +import shutil +from collections.abc import Callable +from pathlib import Path + +from hls_metadata_validation.cmr import check_cmr, normalize_cmr +from hls_metadata_validation.cog import check_cog +from hls_metadata_validation.discover import ( + COG_DUMP_SUFFIX, + GranuleFiles, + discover, + load_cog_doc, +) +from hls_metadata_validation.findings import Finding, FindingSurface, Report +from hls_metadata_validation.spec import ProductSpec, load_spec +from hls_metadata_validation.stac import check_stac, load_stac + + +def safe_load[T]( + loader: Callable[[Path], T], + path: Path, + *, + granule: str, + target: str, + surface: FindingSurface, +) -> tuple[T | None, Finding | None]: + """Run ``loader(path)``; turn read/parse failures into a finding.""" + try: + return loader(path), None + except (OSError, ValueError) as e: + return None, Finding( + "error", + surface, + "file.unreadable", + granule, + target, + f"cannot read {path.name}: {e}", + ) + + +def validate_granule(g: GranuleFiles, spec: ProductSpec) -> list[Finding]: + """Check every surface of one granule against ``spec``.""" + + def missing(target: str, what: str) -> Finding: + return Finding( + "error", "spec", "file.required", g.granule, target, f"{what} not found" + ) + + findings: list[Finding] = [] + required_tags = [ + a.cog_tag + for a in spec.attributes.values() + if a.cog_tag is not None and a.required + ] + tag_types = { + a.cog_tag: a.type for a in spec.attributes.values() if a.cog_tag is not None + } + for asset in spec.assets.values(): + path = g.cogs.get(asset.name) + if path is None: + findings.append(missing(asset.name, f"COG {asset.file_suffix}")) + continue + doc, problem = safe_load( + load_cog_doc, path, granule=g.granule, target=asset.name, surface="cog" + ) + if doc is None: + findings.extend([problem] if problem else []) + continue + findings.extend( + check_cog( + doc, + asset, + granule=g.granule, + required_tags=required_tags if asset.granule_tags else [], + tag_types=tag_types if asset.granule_tags else {}, + ) + ) + for extra in sorted(set(g.cogs) - set(spec.assets)): + findings.append( + Finding( + "warning", + "spec", + "file.unexpected", + g.granule, + extra, + "COG asset not declared in the spec", + ) + ) + + if g.cmr is None: + findings.append(missing("cmr.xml", "CMR XML")) + else: + findings.extend(check_cmr(normalize_cmr(g.cmr), spec, granule=g.granule)) + if g.stac is None: + findings.append(missing("stac.json", "STAC Item")) + else: + stac_doc, problem = safe_load( + load_stac, g.stac, granule=g.granule, target="stac.json", surface="stac" + ) + if stac_doc is None: + findings.extend([problem] if problem else []) + else: + findings.extend(check_stac(stac_doc, spec, granule=g.granule)) + if g.thumbnail is None: + findings.append(missing("jpg", "thumbnail")) + return findings + + +def validate_dir(product_dir: Path, product: str | None = None) -> Report: + """Validate every granule found under ``product_dir``.""" + report = Report() + granules = discover(product_dir) + if not granules: + report.add( + Finding( + "error", + "spec", + "discover.empty", + "-", + str(product_dir), + "no HLS granule outputs found", + ) + ) + return report + specs: dict[str, ProductSpec] = {} + for g in granules: + name = product or g.product + spec = specs.setdefault(name, load_spec(name)) + sub = Report(validate_granule(g, spec)) + sub.apply_known_gaps(spec) + report.extend(sub.findings) + return report + + +def dump_dir(product_dir: Path, out_dir: Path) -> list[Path]: + """Write normalized COG docs and copy CMR/STAC/thumbnail per granule.""" + written: list[Path] = [] + for g in discover(product_dir): + dest = Path(out_dir) / g.granule + dest.mkdir(parents=True, exist_ok=True) + for asset, path in sorted(g.cogs.items()): + out = dest / f"{g.granule}.{asset}{COG_DUMP_SUFFIX}" + out.write_text( + json.dumps(load_cog_doc(path), indent=2, sort_keys=True) + "\n" + ) + written.append(out) + for src in (g.cmr, g.stac, g.thumbnail): + if src is not None: + written.append(Path(shutil.copy(src, dest / src.name))) + return written diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/spec.py b/src/hls-metadata-validation/src/hls_metadata_validation/spec.py new file mode 100644 index 00000000..f0a29be9 --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/spec.py @@ -0,0 +1,193 @@ +"""Product metadata spec model and loader.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from importlib.resources import files +from pathlib import Path +from typing import Any, Literal, cast + +import jsonschema + +AttributeType = Literal["string", "number", "integer", "datetime"] +GoldenMode = Literal["exact", "approx", "volatile"] +Surface = Literal["cog", "cmr", "stac", "golden", "spec"] + +INCLUDE_KEY = "$include" + + +class SpecError(Exception): + """Raised when a spec document is malformed.""" + + +@dataclass(frozen=True) +class AttributeSpec: + name: str + required: bool + type: AttributeType + cog_tag: str | None + cmr_additional_attribute: str | None + stac_property: str | None + value_from: str | None + golden: GoldenMode + tolerance: float | None + + +@dataclass(frozen=True) +class AssetSpec: + name: str + file_suffix: str + dtype: str + nodata: float | int + scale_factor: float | None + add_offset: float | None + stac_asset: str | None + granule_tags: bool = True + + +@dataclass(frozen=True) +class KnownGap: + id: str + surface: Surface + check: str + target: str | None + reason: str + + def matches(self, surface: str, check: str, target: str) -> bool: + if self.surface != surface or self.check != check: + return False + return self.target is None or target in self.target.split("|") + + +@dataclass(frozen=True) +class ProductSpec: + product: str + version: str + attributes: dict[str, AttributeSpec] + assets: dict[str, AssetSpec] + known_gaps: tuple[KnownGap, ...] + default_tolerance: float + + +def _specs_dir() -> Path: + return Path(str(files("hls_metadata_validation") / "specs")) + + +def _schema() -> dict[str, Any]: + return cast( + dict[str, Any], json.loads((_specs_dir() / "spec.schema.json").read_text()) + ) + + +def _read_json(path: Path) -> Any: + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as e: + raise SpecError(f"cannot read spec file {path}: {e}") from e + + +def _resolve(node: Any, base: Path) -> Any: + """Replace ``{"$include": ...}`` objects with the merged referenced files.""" + if isinstance(node, list): + return [_resolve(item, base) for item in node] + if not isinstance(node, dict): + return node + if INCLUDE_KEY not in node: + return {k: _resolve(v, base) for k, v in node.items()} + + includes = node[INCLUDE_KEY] + if isinstance(includes, str): + includes = [includes] + merged: dict[str, Any] = {} + sources: list[tuple[str, dict[str, Any]]] = [] + for rel in includes: + path = (base / rel).resolve() + content = _resolve(_read_json(path), path.parent) + if not isinstance(content, dict): + raise SpecError(f"included file {path} is not a JSON object") + sources.append((str(path), content)) + inline = {k: _resolve(v, base) for k, v in node.items() if k != INCLUDE_KEY} + sources.append(("inline", inline)) + for origin, content in sources: + for key, value in content.items(): + if key in merged: + raise SpecError(f"duplicate key {key!r} from {origin}") + merged[key] = value + return merged + + +def _build(doc: dict[str, Any]) -> ProductSpec: + attributes = { + name: AttributeSpec( + name=name, + required=a.get("required", True), + type=a.get("type", "string"), + cog_tag=a.get("cog_tag"), + cmr_additional_attribute=a.get("cmr_additional_attribute"), + stac_property=a.get("stac_property"), + value_from=a.get("value_from"), + golden=a.get("golden", "exact"), + tolerance=a.get("tolerance"), + ) + for name, a in doc["attributes"].items() + } + assets = { + name: AssetSpec( + name=name, + file_suffix=a.get("file_suffix", f"{name}.tif"), + dtype=a["dtype"], + nodata=a["nodata"], + scale_factor=a.get("scale_factor"), + add_offset=a.get("add_offset"), + stac_asset=a.get("stac_asset"), + granule_tags=a.get("granule_tags", True), + ) + for name, a in doc["assets"].items() + } + gaps = tuple( + KnownGap( + id=g["id"], + surface=g["surface"], + check=g["check"], + target=g.get("target"), + reason=g["reason"], + ) + for g in doc["known_gaps"] + ) + for attr in attributes.values(): + if attr.value_from is not None: + asset_name = attr.value_from.split(".", 1)[0] + if asset_name not in assets: + raise SpecError( + f"attribute {attr.name}: value_from refers to unknown asset " + f"{asset_name!r}" + ) + return ProductSpec( + product=doc["product"], + version=doc["version"], + attributes=attributes, + assets=assets, + known_gaps=gaps, + default_tolerance=float(doc["default_tolerance"]), + ) + + +def load_spec_file(path: Path) -> ProductSpec: + """Load, resolve includes in, and validate a spec file.""" + path = Path(path) + doc = _resolve(_read_json(path), path.parent) + try: + jsonschema.validate(doc, _schema()) + except jsonschema.ValidationError as e: + loc = "/".join(str(p) for p in e.absolute_path) or "" + raise SpecError(f"{path}: {loc}: {e.message}") from e + return _build(doc) + + +def load_spec(product: str) -> ProductSpec: + """Load the packaged spec for ``product`` (e.g. ``"L30"``).""" + path = _specs_dir() / f"{product}.json" + if not path.is_file(): + raise SpecError(f"no packaged spec for product {product!r}") + return load_spec_file(path) diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/specs/L30.json b/src/hls-metadata-validation/src/hls_metadata_validation/specs/L30.json new file mode 100644 index 00000000..1e90d868 --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/specs/L30.json @@ -0,0 +1,119 @@ +{ + "product": "L30", + "version": "2.0", + "default_tolerance": 1e-06, + "attributes": { + "$include": "common/attributes.json", + "LANDSAT_PRODUCT_ID": { + "required": true, + "type": "string", + "cog_tag": "LANDSAT_PRODUCT_ID", + "cmr_additional_attribute": "LANDSAT_PRODUCT_ID", + "stac_property": null, + "golden": "exact" + }, + "MGRS_TILE_ID": { + "required": true, + "type": "string", + "cog_tag": "SENTINEL2_TILEID", + "cmr_additional_attribute": "MGRS_TILE_ID", + "stac_property": null, + "golden": "exact" + }, + "THERM_SCALE_FACTOR": { + "required": true, + "type": "number", + "cog_tag": null, + "cmr_additional_attribute": "THERM_SCALE_FACTOR", + "stac_property": null, + "value_from": "B10.scale_factor", + "golden": "exact" + }, + "TIRS_SSM_MODEL": { + "required": true, + "type": "string", + "cog_tag": "TIRS_SSM_MODEL", + "cmr_additional_attribute": "TIRS_SSM_MODEL", + "stac_property": null, + "golden": "exact" + }, + "TIRS_SSM_POSITION_STATUS": { + "required": true, + "type": "string", + "cog_tag": "TIRS_SSM_POSITION_STATUS", + "cmr_additional_attribute": "TIRS_SSM_POSITION_STATUS", + "stac_property": null, + "golden": "exact" + }, + "USGS_SOFTWARE": { + "required": true, + "type": "string", + "cog_tag": "USGS_SOFTWARE", + "cmr_additional_attribute": "USGS_SOFTWARE", + "stac_property": null, + "golden": "volatile" + } + }, + "assets": { + "$include": [ + "common/angles.json", + "common/fmask.json" + ], + "B01": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B01" + }, + "B02": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B02" + }, + "B03": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B03" + }, + "B04": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B04" + }, + "B05": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B05" + }, + "B06": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B06" + }, + "B07": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B07" + }, + "B09": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B09" + }, + "B10": { + "$include": "common/encodings/thermal.json", + "stac_asset": "B10" + }, + "B11": { + "$include": "common/encodings/thermal.json", + "stac_asset": "B11" + } + }, + "known_gaps": [ + { + "id": "stac-no-raster-ext", + "surface": "stac", + "check": "asset.raster", + "target": null, + "reason": "hls-cmr_stac does not add the raster extension, so STAC assets carry no nodata/scale/offset." + }, + { + "id": "prod-l30-angle-attrs", + "surface": "golden", + "check": "value", + "target": "SZA|SAA|VZA|VAA", + "reason": "Prod L30 angle COGs predate the restore of NASA-IMPACT/hls-landsat-tile@60205b0 (nodata/scale/offset on L30 angle SDS); remove once references are regenerated." + } + ] +} diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/specs/S30.json b/src/hls-metadata-validation/src/hls_metadata_validation/specs/S30.json new file mode 100644 index 00000000..c22126f5 --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/specs/S30.json @@ -0,0 +1,203 @@ +{ + "product": "S30", + "version": "2.0", + "default_tolerance": 1e-06, + "attributes": { + "$include": "common/attributes.json", + "AROP_AVE_XSHIFT(METERS)": { + "required": true, + "type": "number", + "cog_tag": "arop_ave_xshift(meters)", + "cmr_additional_attribute": "AROP_AVE_XSHIFT(METERS)", + "stac_property": null, + "golden": "approx" + }, + "AROP_AVE_YSHIFT(METERS)": { + "required": true, + "type": "number", + "cog_tag": "arop_ave_yshift(meters)", + "cmr_additional_attribute": "AROP_AVE_YSHIFT(METERS)", + "stac_property": null, + "golden": "approx" + }, + "AROP_NCP": { + "required": true, + "type": "integer", + "cog_tag": "arop_ncp", + "cmr_additional_attribute": "AROP_NCP", + "stac_property": null, + "golden": "exact" + }, + "AROP_RMSE(METERS)": { + "required": true, + "type": "number", + "cog_tag": "arop_rmse(meters)", + "cmr_additional_attribute": "AROP_RMSE(METERS)", + "stac_property": null, + "golden": "approx" + }, + "AROP_S2_REFIMG": { + "required": true, + "type": "string", + "cog_tag": "arop_s2_refimg", + "cmr_additional_attribute": "AROP_S2_REFIMG", + "stac_property": null, + "golden": "exact" + }, + "HORIZONTAL_CS_CODE": { + "required": true, + "type": "string", + "cog_tag": "HORIZONTAL_CS_CODE", + "cmr_additional_attribute": "HORIZONTAL_CS_CODE", + "stac_property": null, + "golden": "exact" + }, + "MGRS_TILE_ID": { + "required": true, + "type": "string", + "cog_tag": null, + "cmr_additional_attribute": "MGRS_TILE_ID", + "stac_property": null, + "golden": "exact" + }, + "MSI_BAND_01_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET": { + "required": true, + "type": "string", + "cog_tag": "MSI band 01 bandpass adjustment slope and offset", + "cmr_additional_attribute": "MSI_BAND_01_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET", + "stac_property": null, + "golden": "exact" + }, + "MSI_BAND_02_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET": { + "required": true, + "type": "string", + "cog_tag": "MSI band 02 bandpass adjustment slope and offset", + "cmr_additional_attribute": "MSI_BAND_02_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET", + "stac_property": null, + "golden": "exact" + }, + "MSI_BAND_03_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET": { + "required": true, + "type": "string", + "cog_tag": "MSI band 03 bandpass adjustment slope and offset", + "cmr_additional_attribute": "MSI_BAND_03_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET", + "stac_property": null, + "golden": "exact" + }, + "MSI_BAND_04_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET": { + "required": true, + "type": "string", + "cog_tag": "MSI band 04 bandpass adjustment slope and offset", + "cmr_additional_attribute": "MSI_BAND_04_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET", + "stac_property": null, + "golden": "exact" + }, + "MSI_BAND_11_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET": { + "required": true, + "type": "string", + "cog_tag": "MSI band 11 bandpass adjustment slope and offset", + "cmr_additional_attribute": "MSI_BAND_11_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET", + "stac_property": null, + "golden": "exact" + }, + "MSI_BAND_12_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET": { + "required": true, + "type": "string", + "cog_tag": "MSI band 12 bandpass adjustment slope and offset", + "cmr_additional_attribute": "MSI_BAND_12_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET", + "stac_property": null, + "golden": "exact" + }, + "MSI_BAND_8A_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET": { + "required": true, + "type": "string", + "cog_tag": "MSI band 8a bandpass adjustment slope and offset", + "cmr_additional_attribute": "MSI_BAND_8A_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET", + "stac_property": null, + "golden": "exact" + }, + "PROCESSING_BASELINE": { + "required": true, + "type": "string", + "cog_tag": "PROCESSING_BASELINE", + "cmr_additional_attribute": "PROCESSING_BASELINE", + "stac_property": null, + "golden": "exact" + }, + "PRODUCT_URI": { + "required": true, + "type": "string", + "cog_tag": "PRODUCT_URI", + "cmr_additional_attribute": "PRODUCT_URI", + "stac_property": null, + "golden": "exact" + } + }, + "assets": { + "$include": [ + "common/angles.json", + "common/fmask.json" + ], + "B01": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B01" + }, + "B02": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B02" + }, + "B03": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B03" + }, + "B04": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B04" + }, + "B05": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B05" + }, + "B06": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B06" + }, + "B07": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B07" + }, + "B08": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B08" + }, + "B8A": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B8A" + }, + "B09": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B09" + }, + "B10": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B10" + }, + "B11": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B11" + }, + "B12": { + "$include": "common/encodings/reflectance.json", + "stac_asset": "B12" + } + }, + "known_gaps": [ + { + "id": "stac-no-raster-ext", + "surface": "stac", + "check": "asset.raster", + "target": null, + "reason": "hls-cmr_stac does not add the raster extension, so STAC assets carry no nodata/scale/offset." + } + ] +} diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/angles.json b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/angles.json new file mode 100644 index 00000000..5f3621af --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/angles.json @@ -0,0 +1,18 @@ +{ + "SZA": { + "$include": "encodings/angle.json", + "stac_asset": "SZA" + }, + "SAA": { + "$include": "encodings/angle.json", + "stac_asset": "SAA" + }, + "VZA": { + "$include": "encodings/angle.json", + "stac_asset": "VZA" + }, + "VAA": { + "$include": "encodings/angle.json", + "stac_asset": "VAA" + } +} diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/attributes.json b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/attributes.json new file mode 100644 index 00000000..bff5c3b4 --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/attributes.json @@ -0,0 +1,209 @@ +{ + "ACCODE": { + "required": true, + "type": "string", + "cog_tag": "ACCODE", + "cmr_additional_attribute": "ACCODE", + "stac_property": "processing:software.Atmospheric Correction", + "golden": "volatile" + }, + "ADD_OFFSET": { + "required": true, + "type": "number", + "cog_tag": null, + "cmr_additional_attribute": "ADD_OFFSET", + "stac_property": null, + "value_from": "B01.add_offset", + "golden": "exact" + }, + "ANG_SCALE_FACTOR": { + "required": true, + "type": "number", + "cog_tag": null, + "cmr_additional_attribute": "ANG_SCALE_FACTOR", + "stac_property": null, + "value_from": "SZA.scale_factor", + "golden": "exact" + }, + "CLOUD_COVERAGE": { + "required": true, + "type": "number", + "cog_tag": "cloud_coverage", + "cmr_additional_attribute": "CLOUD_COVERAGE", + "stac_property": "eo:cloud_cover", + "golden": "approx", + "tolerance": 1.0 + }, + "CLOUD_MASKING_CODE": { + "required": true, + "type": "string", + "cog_tag": "CLOUD_MASKING_CODE", + "cmr_additional_attribute": "CLOUD_MASKING_CODE", + "stac_property": "processing:software.Cloud Masking", + "golden": "volatile" + }, + "FILLVALUE": { + "required": true, + "type": "number", + "cog_tag": null, + "cmr_additional_attribute": "FILLVALUE", + "stac_property": null, + "value_from": "B01.nodata", + "golden": "exact" + }, + "HLS_PROCESSING_TIME": { + "required": true, + "type": "datetime", + "cog_tag": "HLS_PROCESSING_TIME", + "cmr_additional_attribute": "HLS_PROCESSING_TIME", + "stac_property": null, + "golden": "volatile" + }, + "HORIZONTAL_CS_NAME": { + "required": true, + "type": "string", + "cog_tag": "HORIZONTAL_CS_NAME", + "cmr_additional_attribute": "HORIZONTAL_CS_NAME", + "stac_property": null, + "golden": "exact" + }, + "IDENTIFIER_PRODUCT_DOI": { + "required": true, + "type": "string", + "cog_tag": null, + "cmr_additional_attribute": "IDENTIFIER_PRODUCT_DOI", + "stac_property": "sci:doi", + "golden": "exact" + }, + "IDENTIFIER_PRODUCT_DOI_AUTHORITY": { + "required": true, + "type": "string", + "cog_tag": null, + "cmr_additional_attribute": "IDENTIFIER_PRODUCT_DOI_AUTHORITY", + "stac_property": null, + "golden": "exact" + }, + "MEAN_SUN_AZIMUTH_ANGLE": { + "required": true, + "type": "number", + "cog_tag": "MEAN_SUN_AZIMUTH_ANGLE", + "cmr_additional_attribute": "MEAN_SUN_AZIMUTH_ANGLE", + "stac_property": "view:sun_azimuth", + "golden": "approx" + }, + "MEAN_SUN_ZENITH_ANGLE": { + "required": true, + "type": "number", + "cog_tag": "MEAN_SUN_ZENITH_ANGLE", + "cmr_additional_attribute": "MEAN_SUN_ZENITH_ANGLE", + "stac_property": null, + "golden": "approx" + }, + "MEAN_VIEW_AZIMUTH_ANGLE": { + "required": true, + "type": "number", + "cog_tag": "MEAN_VIEW_AZIMUTH_ANGLE", + "cmr_additional_attribute": "MEAN_VIEW_AZIMUTH_ANGLE", + "stac_property": "view:azimuth", + "golden": "approx" + }, + "MEAN_VIEW_ZENITH_ANGLE": { + "required": true, + "type": "number", + "cog_tag": "MEAN_VIEW_ZENITH_ANGLE", + "cmr_additional_attribute": "MEAN_VIEW_ZENITH_ANGLE", + "stac_property": null, + "golden": "approx" + }, + "NBAR_SOLAR_ZENITH": { + "required": true, + "type": "number", + "cog_tag": "NBAR_SOLAR_ZENITH", + "cmr_additional_attribute": "NBAR_SOLAR_ZENITH", + "stac_property": null, + "golden": "approx" + }, + "NCOLS": { + "required": true, + "type": "integer", + "cog_tag": "NCOLS", + "cmr_additional_attribute": "NCOLS", + "stac_property": null, + "golden": "exact" + }, + "NROWS": { + "required": true, + "type": "integer", + "cog_tag": "NROWS", + "cmr_additional_attribute": "NROWS", + "stac_property": null, + "golden": "exact" + }, + "QA_FILLVALUE": { + "required": true, + "type": "number", + "cog_tag": null, + "cmr_additional_attribute": "QA_FILLVALUE", + "stac_property": null, + "value_from": "Fmask.nodata", + "golden": "exact" + }, + "REF_SCALE_FACTOR": { + "required": true, + "type": "number", + "cog_tag": null, + "cmr_additional_attribute": "REF_SCALE_FACTOR", + "stac_property": null, + "value_from": "B01.scale_factor", + "golden": "exact" + }, + "SENSING_TIME": { + "required": true, + "type": "datetime", + "cog_tag": "SENSING_TIME", + "cmr_additional_attribute": "SENSING_TIME", + "stac_property": null, + "golden": "exact" + }, + "SPATIAL_COVERAGE": { + "required": true, + "type": "number", + "cog_tag": "spatial_coverage", + "cmr_additional_attribute": "SPATIAL_COVERAGE", + "stac_property": null, + "golden": "approx", + "tolerance": 1.0 + }, + "SPATIAL_RESAMPLING_ALG": { + "required": true, + "type": "string", + "cog_tag": null, + "cmr_additional_attribute": "SPATIAL_RESAMPLING_ALG", + "stac_property": null, + "golden": "exact" + }, + "SPATIAL_RESOLUTION": { + "required": true, + "type": "number", + "cog_tag": "SPATIAL_RESOLUTION", + "cmr_additional_attribute": "SPATIAL_RESOLUTION", + "stac_property": null, + "golden": "exact" + }, + "ULX": { + "required": true, + "type": "number", + "cog_tag": "ULX", + "cmr_additional_attribute": "ULX", + "stac_property": null, + "golden": "approx" + }, + "ULY": { + "required": true, + "type": "number", + "cog_tag": "ULY", + "cmr_additional_attribute": "ULY", + "stac_property": null, + "golden": "approx" + } +} diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/angle.json b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/angle.json new file mode 100644 index 00000000..04fb2ba5 --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/angle.json @@ -0,0 +1,7 @@ +{ + "dtype": "uint16", + "nodata": 40000, + "scale_factor": 0.01, + "add_offset": 0.0, + "granule_tags": false +} diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/fmask.json b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/fmask.json new file mode 100644 index 00000000..e5ebb4d0 --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/fmask.json @@ -0,0 +1,6 @@ +{ + "dtype": "uint8", + "nodata": 255, + "scale_factor": null, + "add_offset": null +} diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/int16_fill.json b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/int16_fill.json new file mode 100644 index 00000000..5b7182c6 --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/int16_fill.json @@ -0,0 +1,4 @@ +{ + "dtype": "int16", + "nodata": -9999 +} diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/reflectance.json b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/reflectance.json new file mode 100644 index 00000000..00565759 --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/reflectance.json @@ -0,0 +1,5 @@ +{ + "$include": "int16_fill.json", + "scale_factor": 0.0001, + "add_offset": 0.0 +} diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/thermal.json b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/thermal.json new file mode 100644 index 00000000..755f2e0a --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/encodings/thermal.json @@ -0,0 +1,5 @@ +{ + "$include": "int16_fill.json", + "scale_factor": 0.01, + "add_offset": 0.0 +} diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/fmask.json b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/fmask.json new file mode 100644 index 00000000..1df17e9c --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/specs/common/fmask.json @@ -0,0 +1,6 @@ +{ + "Fmask": { + "$include": "encodings/fmask.json", + "stac_asset": "Fmask" + } +} diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/specs/spec.schema.json b/src/hls-metadata-validation/src/hls_metadata_validation/specs/spec.schema.json new file mode 100644 index 00000000..a744cbdb --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/specs/spec.schema.json @@ -0,0 +1,178 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "HLS product metadata spec", + "type": "object", + "additionalProperties": false, + "required": [ + "product", + "version", + "default_tolerance", + "attributes", + "assets", + "known_gaps" + ], + "properties": { + "product": { + "type": "string" + }, + "version": { + "type": "string" + }, + "default_tolerance": { + "type": "number", + "minimum": 0 + }, + "attributes": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/attribute" + } + }, + "assets": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/asset" + } + }, + "known_gaps": { + "type": "array", + "items": { + "$ref": "#/$defs/known_gap" + } + } + }, + "$defs": { + "nullable_string": { + "type": [ + "string", + "null" + ] + }, + "attribute": { + "type": "object", + "additionalProperties": false, + "properties": { + "required": { + "type": "boolean" + }, + "type": { + "enum": [ + "string", + "number", + "integer", + "datetime" + ] + }, + "cog_tag": { + "$ref": "#/$defs/nullable_string" + }, + "cmr_additional_attribute": { + "$ref": "#/$defs/nullable_string" + }, + "stac_property": { + "$ref": "#/$defs/nullable_string" + }, + "value_from": { + "type": [ + "string", + "null" + ], + "pattern": "^[^.]+\\.[a-z_]+$" + }, + "golden": { + "enum": [ + "exact", + "approx", + "volatile" + ] + }, + "tolerance": { + "type": [ + "number", + "null" + ], + "minimum": 0 + } + } + }, + "asset": { + "type": "object", + "additionalProperties": false, + "required": [ + "dtype", + "nodata" + ], + "properties": { + "file_suffix": { + "type": "string" + }, + "dtype": { + "enum": [ + "uint8", + "int8", + "uint16", + "int16", + "uint32", + "int32", + "float32", + "float64" + ] + }, + "nodata": { + "type": "number" + }, + "scale_factor": { + "type": [ + "number", + "null" + ] + }, + "add_offset": { + "type": [ + "number", + "null" + ] + }, + "stac_asset": { + "$ref": "#/$defs/nullable_string" + }, + "granule_tags": { + "type": "boolean" + } + } + }, + "known_gap": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "surface", + "check", + "reason" + ], + "properties": { + "id": { + "type": "string" + }, + "surface": { + "enum": [ + "cog", + "cmr", + "stac", + "golden", + "spec" + ] + }, + "check": { + "type": "string" + }, + "target": { + "$ref": "#/$defs/nullable_string" + }, + "reason": { + "type": "string" + } + } + } + } +} diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/stac.py b/src/hls-metadata-validation/src/hls_metadata_validation/stac.py new file mode 100644 index 00000000..1910806c --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/stac.py @@ -0,0 +1,96 @@ +"""Check STAC Items with pystac and against the product spec.""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path +from typing import Any, cast + +import pystac +from pystac.errors import STACValidationError +from pystac.validation import GetSchemaError, validate_dict + +from hls_metadata_validation.findings import Finding +from hls_metadata_validation.spec import ProductSpec + +MISSING = object() + + +def load_stac(path: Path) -> dict[str, Any]: + """Load a STAC Item JSON file.""" + return cast(dict[str, Any], json.loads(Path(path).read_text())) + + +def get_property(properties: dict[str, Any], path: str) -> Any: + """Look up ``"a.b"`` style paths; the first segment may contain ``:``.""" + node: Any = properties + for part in path.split("."): + if not isinstance(node, dict) or part not in node: + return MISSING + node = node[part] + return node + + +def _pystac_findings(doc: dict[str, Any], granule: str) -> list[Finding]: + def err(check: str, message: str) -> Finding: + return Finding("error", "stac", check, granule, "stac.json", message) + + try: + pystac.Item.from_dict(copy.deepcopy(doc), preserve_dict=True) + except Exception as e: + return [err("schema", f"not a valid STAC Item: {e}")] + try: + validate_dict(doc) + except STACValidationError as e: + return [err("schema", str(e))] + except GetSchemaError as e: + return [err("schema.unavailable", f"cannot fetch a declared schema: {e}")] + return [] + + +def check_stac( + doc: dict[str, Any], spec: ProductSpec, *, granule: str +) -> list[Finding]: + """Check a STAC Item against core/extension schemas and the product spec.""" + + def err(check: str, target: str, message: str) -> Finding: + return Finding("error", "stac", check, granule, target, message) + + if not isinstance(doc, dict): + return [err("doc.type", "stac.json", "STAC Item is not a JSON object")] + for key in ("properties", "assets"): + if not isinstance(doc.get(key, {}), dict): + return [err("doc.type", key, f"{key!r} is not a JSON object")] + + findings = _pystac_findings(doc, granule) + + properties = doc.get("properties", {}) + for attr in spec.attributes.values(): + if attr.stac_property is None: + continue + value = get_property(properties, attr.stac_property) + if attr.required and (value is MISSING or value is None or value == ""): + findings.append( + err( + "property.required", attr.stac_property, "required property missing" + ) + ) + + assets = doc.get("assets", {}) + for asset in spec.assets.values(): + if asset.stac_asset is None: + continue + stac_asset = assets.get(asset.stac_asset) + if stac_asset is None: + findings.append(err("asset.required", asset.stac_asset, "asset missing")) + continue + if "raster:bands" not in stac_asset: + findings.append( + err( + "asset.raster", + asset.stac_asset, + "asset has no raster:bands (nodata/scale/offset)", + ) + ) + return findings diff --git a/src/hls-metadata-validation/src/hls_metadata_validation/valuetypes.py b/src/hls-metadata-validation/src/hls_metadata_validation/valuetypes.py new file mode 100644 index 00000000..08e7e840 --- /dev/null +++ b/src/hls-metadata-validation/src/hls_metadata_validation/valuetypes.py @@ -0,0 +1,25 @@ +"""Check string values against spec attribute types.""" + +from __future__ import annotations + +import re + +from hls_metadata_validation.spec import AttributeType + +DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$") + + +def matches_type(value: str, type_: AttributeType) -> bool: + """Return whether ``value`` is a valid textual ``type_`` value.""" + text = value.strip() + if type_ == "string": + return True + if type_ == "datetime": + return DATETIME_RE.match(text) is not None + try: + number = float(text) + except ValueError: + return False + if type_ == "integer": + return number.is_integer() + return True diff --git a/src/hls-metadata-validation/tests/conftest.py b/src/hls-metadata-validation/tests/conftest.py new file mode 100644 index 00000000..4f4b39d5 --- /dev/null +++ b/src/hls-metadata-validation/tests/conftest.py @@ -0,0 +1,36 @@ +from pathlib import Path + +import pytest +from fixtures import DATA, L30_GRANULE, copy_granule + +from hls_metadata_validation.spec import ProductSpec, load_spec + + +@pytest.fixture +def l30() -> ProductSpec: + return load_spec("L30") + + +@pytest.fixture +def l30_cmr_path() -> Path: + return DATA / "L30" / f"{L30_GRANULE}.cmr.xml" + + +@pytest.fixture +def l30_stac_path() -> Path: + return DATA / "L30" / f"{L30_GRANULE}_stac.json" + + +@pytest.fixture +def s30_dir(tmp_path: Path) -> Path: + """A products dir holding the real S30 fixture granule (passes the spec).""" + copy_granule("S30", tmp_path / "products") + return tmp_path / "products" + + +@pytest.fixture +def two_granule_dir(s30_dir: Path) -> Path: + """The S30 products dir plus the real L30 granule and an unrelated file.""" + copy_granule("L30", s30_dir) + (s30_dir / "notes.txt").write_text("unrelated") + return s30_dir diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B01.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B01.tif.json new file mode 100644 index 00000000..f6ad76ff --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B01.tif.json @@ -0,0 +1,68 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + "Coastal_Aerosol" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "HLS_PROCESSING_TIME": "2026-11-11T18:43:13Z", + "HORIZONTAL_CS_NAME": "UTM, WGS84, UTM ZONE 56", + "L1_PROCESSING_TIME": "2026-11-06T02:12:27Z", + "LANDSAT_PRODUCT_ID": "LC08_L1TP_093057_20261105_20261106_02_RT", + "LANDSAT_SCENE_ID": "LC80930572026309LGN00", + "MEAN_SUN_AZIMUTH_ANGLE": "133.865122124127", + "MEAN_SUN_ZENITH_ANGLE": "29.1862248403569", + "MEAN_VIEW_AZIMUTH_ANGLE": "280.504155314953", + "MEAN_VIEW_ZENITH_ANGLE": "7.48156720053351", + "NBAR_SOLAR_ZENITH": "28.0528692713534", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_LEVEL": "L1TP", + "SENSING_TIME": "2026-11-05T23:58:13.8623049Z", + "SENSOR": "OLI_TIRS", + "SENTINEL2_TILEID": "56NQK", + "SPATIAL_RESOLUTION": "30", + "TIRS_SSM_MODEL": "PRELIMINARY", + "TIRS_SSM_POSITION_STATUS": "ESTIMATED", + "ULX": "699960", + "ULY": "500040", + "USGS_SOFTWARE": "LPGS_16.6.0", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "92", + "long_name": "Coastal_Aerosol", + "scale_factor": "0.0001", + "spatial_coverage": "16" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B02.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B02.tif.json new file mode 100644 index 00000000..212f2d85 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B02.tif.json @@ -0,0 +1,68 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + "Blue" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "HLS_PROCESSING_TIME": "2026-11-11T18:43:13Z", + "HORIZONTAL_CS_NAME": "UTM, WGS84, UTM ZONE 56", + "L1_PROCESSING_TIME": "2026-11-06T02:12:27Z", + "LANDSAT_PRODUCT_ID": "LC08_L1TP_093057_20261105_20261106_02_RT", + "LANDSAT_SCENE_ID": "LC80930572026309LGN00", + "MEAN_SUN_AZIMUTH_ANGLE": "133.865122124127", + "MEAN_SUN_ZENITH_ANGLE": "29.1862248403569", + "MEAN_VIEW_AZIMUTH_ANGLE": "280.504155314953", + "MEAN_VIEW_ZENITH_ANGLE": "7.48156720053351", + "NBAR_SOLAR_ZENITH": "28.0528692713534", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_LEVEL": "L1TP", + "SENSING_TIME": "2026-11-05T23:58:13.8623049Z", + "SENSOR": "OLI_TIRS", + "SENTINEL2_TILEID": "56NQK", + "SPATIAL_RESOLUTION": "30", + "TIRS_SSM_MODEL": "PRELIMINARY", + "TIRS_SSM_POSITION_STATUS": "ESTIMATED", + "ULX": "699960", + "ULY": "500040", + "USGS_SOFTWARE": "LPGS_16.6.0", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "92", + "long_name": "Blue", + "scale_factor": "0.0001", + "spatial_coverage": "16" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B03.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B03.tif.json new file mode 100644 index 00000000..42735d04 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B03.tif.json @@ -0,0 +1,68 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + "Green" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "HLS_PROCESSING_TIME": "2026-11-11T18:43:13Z", + "HORIZONTAL_CS_NAME": "UTM, WGS84, UTM ZONE 56", + "L1_PROCESSING_TIME": "2026-11-06T02:12:27Z", + "LANDSAT_PRODUCT_ID": "LC08_L1TP_093057_20261105_20261106_02_RT", + "LANDSAT_SCENE_ID": "LC80930572026309LGN00", + "MEAN_SUN_AZIMUTH_ANGLE": "133.865122124127", + "MEAN_SUN_ZENITH_ANGLE": "29.1862248403569", + "MEAN_VIEW_AZIMUTH_ANGLE": "280.504155314953", + "MEAN_VIEW_ZENITH_ANGLE": "7.48156720053351", + "NBAR_SOLAR_ZENITH": "28.0528692713534", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_LEVEL": "L1TP", + "SENSING_TIME": "2026-11-05T23:58:13.8623049Z", + "SENSOR": "OLI_TIRS", + "SENTINEL2_TILEID": "56NQK", + "SPATIAL_RESOLUTION": "30", + "TIRS_SSM_MODEL": "PRELIMINARY", + "TIRS_SSM_POSITION_STATUS": "ESTIMATED", + "ULX": "699960", + "ULY": "500040", + "USGS_SOFTWARE": "LPGS_16.6.0", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "92", + "long_name": "Green", + "scale_factor": "0.0001", + "spatial_coverage": "16" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B04.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B04.tif.json new file mode 100644 index 00000000..f32ff7b7 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B04.tif.json @@ -0,0 +1,68 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + "Red" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "HLS_PROCESSING_TIME": "2026-11-11T18:43:13Z", + "HORIZONTAL_CS_NAME": "UTM, WGS84, UTM ZONE 56", + "L1_PROCESSING_TIME": "2026-11-06T02:12:27Z", + "LANDSAT_PRODUCT_ID": "LC08_L1TP_093057_20261105_20261106_02_RT", + "LANDSAT_SCENE_ID": "LC80930572026309LGN00", + "MEAN_SUN_AZIMUTH_ANGLE": "133.865122124127", + "MEAN_SUN_ZENITH_ANGLE": "29.1862248403569", + "MEAN_VIEW_AZIMUTH_ANGLE": "280.504155314953", + "MEAN_VIEW_ZENITH_ANGLE": "7.48156720053351", + "NBAR_SOLAR_ZENITH": "28.0528692713534", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_LEVEL": "L1TP", + "SENSING_TIME": "2026-11-05T23:58:13.8623049Z", + "SENSOR": "OLI_TIRS", + "SENTINEL2_TILEID": "56NQK", + "SPATIAL_RESOLUTION": "30", + "TIRS_SSM_MODEL": "PRELIMINARY", + "TIRS_SSM_POSITION_STATUS": "ESTIMATED", + "ULX": "699960", + "ULY": "500040", + "USGS_SOFTWARE": "LPGS_16.6.0", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "92", + "long_name": "Red", + "scale_factor": "0.0001", + "spatial_coverage": "16" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B05.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B05.tif.json new file mode 100644 index 00000000..a77afc1e --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B05.tif.json @@ -0,0 +1,68 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + "NIR" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "HLS_PROCESSING_TIME": "2026-11-11T18:43:13Z", + "HORIZONTAL_CS_NAME": "UTM, WGS84, UTM ZONE 56", + "L1_PROCESSING_TIME": "2026-11-06T02:12:27Z", + "LANDSAT_PRODUCT_ID": "LC08_L1TP_093057_20261105_20261106_02_RT", + "LANDSAT_SCENE_ID": "LC80930572026309LGN00", + "MEAN_SUN_AZIMUTH_ANGLE": "133.865122124127", + "MEAN_SUN_ZENITH_ANGLE": "29.1862248403569", + "MEAN_VIEW_AZIMUTH_ANGLE": "280.504155314953", + "MEAN_VIEW_ZENITH_ANGLE": "7.48156720053351", + "NBAR_SOLAR_ZENITH": "28.0528692713534", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_LEVEL": "L1TP", + "SENSING_TIME": "2026-11-05T23:58:13.8623049Z", + "SENSOR": "OLI_TIRS", + "SENTINEL2_TILEID": "56NQK", + "SPATIAL_RESOLUTION": "30", + "TIRS_SSM_MODEL": "PRELIMINARY", + "TIRS_SSM_POSITION_STATUS": "ESTIMATED", + "ULX": "699960", + "ULY": "500040", + "USGS_SOFTWARE": "LPGS_16.6.0", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "92", + "long_name": "NIR", + "scale_factor": "0.0001", + "spatial_coverage": "16" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B06.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B06.tif.json new file mode 100644 index 00000000..4b1a0955 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B06.tif.json @@ -0,0 +1,68 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + "SWIR1" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "HLS_PROCESSING_TIME": "2026-11-11T18:43:13Z", + "HORIZONTAL_CS_NAME": "UTM, WGS84, UTM ZONE 56", + "L1_PROCESSING_TIME": "2026-11-06T02:12:27Z", + "LANDSAT_PRODUCT_ID": "LC08_L1TP_093057_20261105_20261106_02_RT", + "LANDSAT_SCENE_ID": "LC80930572026309LGN00", + "MEAN_SUN_AZIMUTH_ANGLE": "133.865122124127", + "MEAN_SUN_ZENITH_ANGLE": "29.1862248403569", + "MEAN_VIEW_AZIMUTH_ANGLE": "280.504155314953", + "MEAN_VIEW_ZENITH_ANGLE": "7.48156720053351", + "NBAR_SOLAR_ZENITH": "28.0528692713534", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_LEVEL": "L1TP", + "SENSING_TIME": "2026-11-05T23:58:13.8623049Z", + "SENSOR": "OLI_TIRS", + "SENTINEL2_TILEID": "56NQK", + "SPATIAL_RESOLUTION": "30", + "TIRS_SSM_MODEL": "PRELIMINARY", + "TIRS_SSM_POSITION_STATUS": "ESTIMATED", + "ULX": "699960", + "ULY": "500040", + "USGS_SOFTWARE": "LPGS_16.6.0", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "92", + "long_name": "SWIR1", + "scale_factor": "0.0001", + "spatial_coverage": "16" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B07.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B07.tif.json new file mode 100644 index 00000000..35982f78 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B07.tif.json @@ -0,0 +1,68 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + "SWIR2" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "HLS_PROCESSING_TIME": "2026-11-11T18:43:13Z", + "HORIZONTAL_CS_NAME": "UTM, WGS84, UTM ZONE 56", + "L1_PROCESSING_TIME": "2026-11-06T02:12:27Z", + "LANDSAT_PRODUCT_ID": "LC08_L1TP_093057_20261105_20261106_02_RT", + "LANDSAT_SCENE_ID": "LC80930572026309LGN00", + "MEAN_SUN_AZIMUTH_ANGLE": "133.865122124127", + "MEAN_SUN_ZENITH_ANGLE": "29.1862248403569", + "MEAN_VIEW_AZIMUTH_ANGLE": "280.504155314953", + "MEAN_VIEW_ZENITH_ANGLE": "7.48156720053351", + "NBAR_SOLAR_ZENITH": "28.0528692713534", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_LEVEL": "L1TP", + "SENSING_TIME": "2026-11-05T23:58:13.8623049Z", + "SENSOR": "OLI_TIRS", + "SENTINEL2_TILEID": "56NQK", + "SPATIAL_RESOLUTION": "30", + "TIRS_SSM_MODEL": "PRELIMINARY", + "TIRS_SSM_POSITION_STATUS": "ESTIMATED", + "ULX": "699960", + "ULY": "500040", + "USGS_SOFTWARE": "LPGS_16.6.0", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "92", + "long_name": "SWIR2", + "scale_factor": "0.0001", + "spatial_coverage": "16" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B09.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B09.tif.json new file mode 100644 index 00000000..5cfbf4ad --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B09.tif.json @@ -0,0 +1,68 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + "Cirrus" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "HLS_PROCESSING_TIME": "2026-11-11T18:43:13Z", + "HORIZONTAL_CS_NAME": "UTM, WGS84, UTM ZONE 56", + "L1_PROCESSING_TIME": "2026-11-06T02:12:27Z", + "LANDSAT_PRODUCT_ID": "LC08_L1TP_093057_20261105_20261106_02_RT", + "LANDSAT_SCENE_ID": "LC80930572026309LGN00", + "MEAN_SUN_AZIMUTH_ANGLE": "133.865122124127", + "MEAN_SUN_ZENITH_ANGLE": "29.1862248403569", + "MEAN_VIEW_AZIMUTH_ANGLE": "280.504155314953", + "MEAN_VIEW_ZENITH_ANGLE": "7.48156720053351", + "NBAR_SOLAR_ZENITH": "28.0528692713534", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_LEVEL": "L1TP", + "SENSING_TIME": "2026-11-05T23:58:13.8623049Z", + "SENSOR": "OLI_TIRS", + "SENTINEL2_TILEID": "56NQK", + "SPATIAL_RESOLUTION": "30", + "TIRS_SSM_MODEL": "PRELIMINARY", + "TIRS_SSM_POSITION_STATUS": "ESTIMATED", + "ULX": "699960", + "ULY": "500040", + "USGS_SOFTWARE": "LPGS_16.6.0", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "92", + "long_name": "Cirrus", + "scale_factor": "0.0001", + "spatial_coverage": "16" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B10.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B10.tif.json new file mode 100644 index 00000000..a2663591 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B10.tif.json @@ -0,0 +1,69 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + "TIRS1" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.01 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "HLS_PROCESSING_TIME": "2026-11-11T18:43:13Z", + "HORIZONTAL_CS_NAME": "UTM, WGS84, UTM ZONE 56", + "L1_PROCESSING_TIME": "2026-11-06T02:12:27Z", + "LANDSAT_PRODUCT_ID": "LC08_L1TP_093057_20261105_20261106_02_RT", + "LANDSAT_SCENE_ID": "LC80930572026309LGN00", + "MEAN_SUN_AZIMUTH_ANGLE": "133.865122124127", + "MEAN_SUN_ZENITH_ANGLE": "29.1862248403569", + "MEAN_VIEW_AZIMUTH_ANGLE": "280.504155314953", + "MEAN_VIEW_ZENITH_ANGLE": "7.48156720053351", + "NBAR_SOLAR_ZENITH": "28.0528692713534", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_LEVEL": "L1TP", + "SENSING_TIME": "2026-11-05T23:58:13.8623049Z", + "SENSOR": "OLI_TIRS", + "SENTINEL2_TILEID": "56NQK", + "SPATIAL_RESOLUTION": "30", + "TIRS_SSM_MODEL": "PRELIMINARY", + "TIRS_SSM_POSITION_STATUS": "ESTIMATED", + "ULX": "699960", + "ULY": "500040", + "USGS_SOFTWARE": "LPGS_16.6.0", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "92", + "long_name": "TIRS1", + "scale_factor": "0.01", + "spatial_coverage": "16", + "unit": "Celsius" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B11.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B11.tif.json new file mode 100644 index 00000000..a47d940e --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.B11.tif.json @@ -0,0 +1,69 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + "TIRS2" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.01 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "HLS_PROCESSING_TIME": "2026-11-11T18:43:13Z", + "HORIZONTAL_CS_NAME": "UTM, WGS84, UTM ZONE 56", + "L1_PROCESSING_TIME": "2026-11-06T02:12:27Z", + "LANDSAT_PRODUCT_ID": "LC08_L1TP_093057_20261105_20261106_02_RT", + "LANDSAT_SCENE_ID": "LC80930572026309LGN00", + "MEAN_SUN_AZIMUTH_ANGLE": "133.865122124127", + "MEAN_SUN_ZENITH_ANGLE": "29.1862248403569", + "MEAN_VIEW_AZIMUTH_ANGLE": "280.504155314953", + "MEAN_VIEW_ZENITH_ANGLE": "7.48156720053351", + "NBAR_SOLAR_ZENITH": "28.0528692713534", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_LEVEL": "L1TP", + "SENSING_TIME": "2026-11-05T23:58:13.8623049Z", + "SENSOR": "OLI_TIRS", + "SENTINEL2_TILEID": "56NQK", + "SPATIAL_RESOLUTION": "30", + "TIRS_SSM_MODEL": "PRELIMINARY", + "TIRS_SSM_POSITION_STATUS": "ESTIMATED", + "ULX": "699960", + "ULY": "500040", + "USGS_SOFTWARE": "LPGS_16.6.0", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "92", + "long_name": "TIRS2", + "scale_factor": "0.01", + "spatial_coverage": "16", + "unit": "Celsius" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.Fmask.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.Fmask.tif.json new file mode 100644 index 00000000..f492d2ef --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.Fmask.tif.json @@ -0,0 +1,66 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + null + ], + "dtype": "uint8", + "height": 3660, + "is_cog": true, + "nodata": 255.0, + "offsets": [ + 0.0 + ], + "scales": [ + 1.0 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "Fmask bit description": "Bits are listed from the MSB (bit 7) to the LSB (bit 0): \n7-6 aerosol:\n 00 - climatology\n 01 - low\n 10 - average\n 11 - high\n5 water\n4 snow/ice\n3 cloud shadow\n2 adjacent to cloud\n1 cloud\n0 cirrus cloud\n", + "HLS_PROCESSING_TIME": "2026-11-11T18:43:13Z", + "HORIZONTAL_CS_NAME": "UTM, WGS84, UTM ZONE 56", + "L1_PROCESSING_TIME": "2026-11-06T02:12:27Z", + "LANDSAT_PRODUCT_ID": "LC08_L1TP_093057_20261105_20261106_02_RT", + "LANDSAT_SCENE_ID": "LC80930572026309LGN00", + "MEAN_SUN_AZIMUTH_ANGLE": "133.865122124127", + "MEAN_SUN_ZENITH_ANGLE": "29.1862248403569", + "MEAN_VIEW_AZIMUTH_ANGLE": "280.504155314953", + "MEAN_VIEW_ZENITH_ANGLE": "7.48156720053351", + "NBAR_SOLAR_ZENITH": "28.0528692713534", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_LEVEL": "L1TP", + "SENSING_TIME": "2026-11-05T23:58:13.8623049Z", + "SENSOR": "OLI_TIRS", + "SENTINEL2_TILEID": "56NQK", + "SPATIAL_RESOLUTION": "30", + "TIRS_SSM_MODEL": "PRELIMINARY", + "TIRS_SSM_POSITION_STATUS": "ESTIMATED", + "ULX": "699960", + "ULY": "500040", + "USGS_SOFTWARE": "LPGS_16.6.0", + "_FillValue": "255", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "92", + "spatial_coverage": "16" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.SAA.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.SAA.tif.json new file mode 100644 index 00000000..87fb5fc8 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.SAA.tif.json @@ -0,0 +1,34 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + null + ], + "dtype": "uint16", + "height": 3660, + "is_cog": true, + "nodata": null, + "offsets": [ + 0.0 + ], + "scales": [ + 1.0 + ], + "tags": { + "AREA_OR_POINT": "Area", + "L1T_SceneID": "2026-11-05_093057_SZA.img", + "OVERVIEW_RESAMPLING": "NEAREST" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.SZA.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.SZA.tif.json new file mode 100644 index 00000000..87fb5fc8 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.SZA.tif.json @@ -0,0 +1,34 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + null + ], + "dtype": "uint16", + "height": 3660, + "is_cog": true, + "nodata": null, + "offsets": [ + 0.0 + ], + "scales": [ + 1.0 + ], + "tags": { + "AREA_OR_POINT": "Area", + "L1T_SceneID": "2026-11-05_093057_SZA.img", + "OVERVIEW_RESAMPLING": "NEAREST" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.VAA.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.VAA.tif.json new file mode 100644 index 00000000..87fb5fc8 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.VAA.tif.json @@ -0,0 +1,34 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + null + ], + "dtype": "uint16", + "height": 3660, + "is_cog": true, + "nodata": null, + "offsets": [ + 0.0 + ], + "scales": [ + 1.0 + ], + "tags": { + "AREA_OR_POINT": "Area", + "L1T_SceneID": "2026-11-05_093057_SZA.img", + "OVERVIEW_RESAMPLING": "NEAREST" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.VZA.tif.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.VZA.tif.json new file mode 100644 index 00000000..87fb5fc8 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.VZA.tif.json @@ -0,0 +1,34 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32656", + "descriptions": [ + null + ], + "dtype": "uint16", + "height": 3660, + "is_cog": true, + "nodata": null, + "offsets": [ + 0.0 + ], + "scales": [ + 1.0 + ], + "tags": { + "AREA_OR_POINT": "Area", + "L1T_SceneID": "2026-11-05_093057_SZA.img", + "OVERVIEW_RESAMPLING": "NEAREST" + }, + "transform": [ + 30.0, + 0.0, + 699960.0, + 0.0, + -30.0, + 500040.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.cmr.xml b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.cmr.xml new file mode 100644 index 00000000..fb1ccc03 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.cmr.xml @@ -0,0 +1,406 @@ + + + HLS.L30.T56NQK.2026309T235813.v2.0 + 2026-11-11T18:43:55.276158Z + 2026-11-11T18:43:55.276174Z + + HLS Landsat Operational Land Imager Surface Reflectance and TOA Brightness Daily Global 30m v2.0 + + + 37804835 + HLS.L30.T56NQK.2026309T235813.v2.0 + DAY + 2026-11-11T18:43:13.000000Z + 2.0 + + + + 2026-11-05T23:58:13.862304Z + 2026-11-05T23:58:13.862304Z + + + + + + + + + 154.80226744 + 4.5216809 + + + 155.07200944 + 4.52095913 + + + 154.85947886 + 3.52871195 + + + 154.80008491 + 3.5288293 + + + + + + + + + LANDSAT-8 + + + OLI + + + + + + + LANDSAT_PRODUCT_ID + + LC08_L1TP_093057_20261105_20261106_02_RT + + + + CLOUD_COVERAGE + + 92 + + + + MGRS_TILE_ID + + 56NQK + + + + SPATIAL_COVERAGE + + 16 + + + + SPATIAL_RESOLUTION + + 30.0 + + + + SPATIAL_RESAMPLING_ALG + + Cubic Convolution + + + + HLS_PROCESSING_TIME + + 2026-11-11T18:43:13Z + + + + SENSING_TIME + + 2026-11-05T23:58:13.8623049Z + + + + HORIZONTAL_CS_NAME + + UTM, WGS84, UTM ZONE 56 + + + + ULX + + 699960.0 + + + + ULY + + 500040.0 + + + + ADD_OFFSET + + 0 + + + + REF_SCALE_FACTOR + + 0.0001 + + + + THERM_SCALE_FACTOR + + 0.01 + + + + ANG_SCALE_FACTOR + + 0.01 + + + + FILLVALUE + + -9999 + + + + QA_FILLVALUE + + 255 + + + + NCOLS + + 3660 + + + + NROWS + + 3660 + + + + MEAN_SUN_AZIMUTH_ANGLE + + 133.86512212 + + + + MEAN_SUN_ZENITH_ANGLE + + 29.18622484 + + + + MEAN_VIEW_AZIMUTH_ANGLE + + 280.50415531 + + + + MEAN_VIEW_ZENITH_ANGLE + + 7.4815672 + + + + NBAR_SOLAR_ZENITH + + 28.05286927 + + + + ACCODE + + LaSRC v3.5.1.0 + + + + CLOUD_MASKING_CODE + + Fmask v5.0.1 + + + + TIRS_SSM_MODEL + + PRELIMINARY + + + + TIRS_SSM_POSITION_STATUS + + ESTIMATED + + + + USGS_SOFTWARE + + LPGS_16.6.0 + + + + IDENTIFIER_PRODUCT_DOI + + 10.5067/HLS/HLSL30.002 + + + + IDENTIFIER_PRODUCT_DOI_AUTHORITY + + https://doi.org + + + + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B11.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.B11.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B10.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.B10.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B07.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.B07.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B05.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.B05.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.Fmask.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.Fmask.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B04.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.B04.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.SAA.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.SAA.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.VZA.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.VZA.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.SZA.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.SZA.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B09.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.B09.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B06.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.B06.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.VAA.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.VAA.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B02.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.B02.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B03.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.B03.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B01.tif + Download HLS.L30.T56NQK.2026309T235813.v2.0.B01.tif + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B11.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B10.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B07.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B05.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.Fmask.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B04.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.SAA.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.VZA.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.SZA.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B09.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B06.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.VAA.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B02.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B03.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B01.tif + This link provides direct download access via S3 to the granule + + + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-public/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0_stac.json + Download HLS.L30.T56NQK.2026309T235813.v2.0_stac.json + EXTENDED METADATA + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.cmr.xml + Download HLS.L30.T56NQK.2026309T235813.v2.0.cmr.xml + EXTENDED METADATA + + + s3://lp-prod-public/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0_stac.json + This link provides direct download access via S3 to the granule + EXTENDED METADATA + + + s3://lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.cmr.xml + This link provides direct download access via S3 to the granule + EXTENDED METADATA + + + https://data.lpdaac.earthdatacloud.nasa.gov/s3credentials + api endpoint to retrieve temporary credentials valid for same-region direct s3 access + VIEW RELATED INFORMATION + + + Cloud Optimized GeoTIFF (COG) + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-public/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.jpg + Download HLS.L30.T56NQK.2026309T235813.v2.0.jpg + + + s3://lp-prod-public/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.jpg + This link provides direct download access via S3 to the granule + + + diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.jpg b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0.jpg new file mode 100644 index 00000000..e69de29b diff --git a/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0_stac.json b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0_stac.json new file mode 100644 index 00000000..3544f45a --- /dev/null +++ b/src/hls-metadata-validation/tests/data/L30/HLS.L30.T56NQK.2026309T235813.v2.0_stac.json @@ -0,0 +1 @@ +{"type": "Feature", "stac_version": "1.0.0", "stac_extensions": ["https://stac-extensions.github.io/eo/v1.1.0/schema.json", "https://stac-extensions.github.io/projection/v1.1.0/schema.json", "https://stac-extensions.github.io/view/v1.0.0/schema.json", "https://stac-extensions.github.io/scientific/v1.0.0/schema.json", "https://stac-extensions.github.io/processing/v1.2.0/schema.json"], "id": "HLS.L30.T56NQK.2026309T235813.v2.0", "geometry": {"type": "MultiPolygon", "coordinates": [[[[154.802267, 4.521681], [155.072009, 4.520959], [154.859479, 3.528712], [154.800085, 3.528829], [154.802267, 4.521681]]]]}, "bbox": [154.800085, 3.528712, 155.072009, 4.521681], "properties": {"start_datetime": "2026-11-05T23:58:13.862304Z", "end_datetime": "2026-11-05T23:58:13.862304Z", "platform": "landsat-8", "instruments": ["oli"], "eo:cloud_cover": 92.0, "proj:transform": [30.0, 0.0, 699960.0, 0.0, -30.0, 500040.0, 0.0, 0.0, 1.0], "proj:shape": [3660, 3660], "proj:epsg": 32656, "view:sun_azimuth": 133.86512212, "view:azimuth": 280.50415531, "sci:doi": "10.5067/HLS/HLSL30.002", "processing:software": {"Atmospheric Correction": "LaSRC v3.5.1.0", "Cloud Masking": "Fmask v5.0.1"}, "datetime": "2026-11-05T23:58:13.862304Z"}, "links": [{"rel": "self", "href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-public/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0_stac.json", "type": "application/json"}, {"rel": "cite-as", "href": "https://doi.org/10.5067/HLS/HLSL30.002"}], "assets": {"B01": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B01.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B01", "common_name": "coastal", "center_wavelength": 0.48, "full_width_half_max": 0.02}], "roles": ["data"]}, "B02": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B02.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B02", "common_name": "blue", "center_wavelength": 0.44, "full_width_half_max": 0.06}], "roles": ["data"]}, "B03": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B03.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B03", "common_name": "green", "center_wavelength": 0.56, "full_width_half_max": 0.06}], "roles": ["data"]}, "B04": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B04.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B04", "common_name": "red", "center_wavelength": 0.65, "full_width_half_max": 0.04}], "roles": ["data"]}, "B05": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B05.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B05", "common_name": "nir", "center_wavelength": 0.86, "full_width_half_max": 0.03}], "roles": ["data"]}, "B06": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B06.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B06", "common_name": "swir16", "center_wavelength": 1.6, "full_width_half_max": 0.08}], "roles": ["data"]}, "B07": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B07.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B07", "common_name": "swir22", "center_wavelength": 2.2, "full_width_half_max": 0.2}], "roles": ["data"]}, "B09": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B09.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B09", "common_name": "cirrus", "center_wavelength": 1.37, "full_width_half_max": 0.02}], "roles": ["data"]}, "B10": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B10.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B10", "common_name": "lwir11", "center_wavelength": 10.9, "full_width_half_max": 0.8}], "roles": ["data"]}, "B11": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.B11.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B11", "common_name": "lwir12", "center_wavelength": 12.0, "full_width_half_max": 1.0}], "roles": ["data"]}, "Fmask": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.Fmask.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "Fmask"}], "roles": ["data"]}, "SZA": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.SZA.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "SZA"}], "roles": ["data"]}, "SAA": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.SAA.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "SAA"}], "roles": ["data"]}, "VZA": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.VZA.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "VZA"}], "roles": ["data"]}, "VAA": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.VAA.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "VAA"}], "roles": ["data"]}, "thumbnail": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-public/HLSL30.020/HLS.L30.T56NQK.2026309T235813.v2.0/HLS.L30.T56NQK.2026309T235813.v2.0.jpg", "type": "image/jpeg", "roles": ["thumbnail"]}}} \ No newline at end of file diff --git a/src/hls-metadata-validation/tests/data/README.md b/src/hls-metadata-validation/tests/data/README.md new file mode 100644 index 00000000..2f7c4147 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/README.md @@ -0,0 +1,27 @@ +# Test fixtures + +Real HLS v2.0 granule metadata, one granule per product: + +| Product | Granule | Fetched | +|---|---|---| +| L30 | `HLS.L30.T56NQK.2026309T235813.v2.0` | 2026-11-12 | +| S30 | `HLS.S30.T55GEM.2026309T235229.v2.0` | 2026-11-12 | + +- `*.cmr.xml`: the `` element of the CMR search API ECHO10 response + (`https://cmr.earthdata.nasa.gov/search/granules.echo10?short_name=HLSL30&version=2.0&page_size=1&sort_key=-start_date`, + likewise `HLSS30`). Edit: the `DataGranule/AdditionalFile` elements were + removed; LP DAAC adds them at ingest, they are not part of the pipeline's + `.cmr.xml`, and `metadata_creator`'s `Granule.xsd` does not allow them. +- `*_stac.json`: the published STAC Item, unmodified, from + `https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-public/HLS{L,S}30.020//_stac.json`. +- `*..tif.json`: `hls-validate-metadata dump` of the published COGs + (`lp-prod-protected/HLS{L,S}30.020//..tif`, Earthdata + login required), fetched 2026-11-12. +- `*.jpg`: zero-byte placeholders; only presence is checked. + +The prod L30 granule predates the restore of NASA-IMPACT/hls-landsat-tile@60205b0 +(#89): its angle COGs have no nodata and an unset scale, so +`hls-validate-metadata spec tests/data/L30` reports exactly 8 errors (nodata and +scale for SZA/SAA/VZA/VAA). `test_runner.py` pins this as a real-world negative +case. A missing add_offset is not detectable on these files because +GDAL reports an unset offset as 0.0, which equals the expected value. diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B01.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B01.tif.json new file mode 100644 index 00000000..82a156fe --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B01.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "Coastal_Aerosol" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "Coastal_Aerosol", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B02.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B02.tif.json new file mode 100644 index 00000000..416c4893 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B02.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "Blue" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "Blue", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B03.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B03.tif.json new file mode 100644 index 00000000..33370d93 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B03.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "Green" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "Green", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B04.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B04.tif.json new file mode 100644 index 00000000..24948887 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B04.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "Red" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "Red", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B05.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B05.tif.json new file mode 100644 index 00000000..75266d43 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B05.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "Red_Edge1" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "Red_Edge1", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B06.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B06.tif.json new file mode 100644 index 00000000..d152ba35 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B06.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "Red_Edge2" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "Red_Edge2", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B07.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B07.tif.json new file mode 100644 index 00000000..b652ba8e --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B07.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "Red_Edge3" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "Red_Edge3", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B08.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B08.tif.json new file mode 100644 index 00000000..94a1c312 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B08.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "NIR_Broad" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "NIR_Broad", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B09.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B09.tif.json new file mode 100644 index 00000000..1234a84c --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B09.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "Water_Vapor" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "Water_Vapor", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B10.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B10.tif.json new file mode 100644 index 00000000..b69bc928 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B10.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "Cirrus" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "Cirrus", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B11.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B11.tif.json new file mode 100644 index 00000000..0ea11176 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B11.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "SWIR1" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "SWIR1", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B12.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B12.tif.json new file mode 100644 index 00000000..7a584b8a --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B12.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "SWIR2" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "SWIR2", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B8A.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B8A.tif.json new file mode 100644 index 00000000..d3b0d113 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.B8A.tif.json @@ -0,0 +1,74 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + "NIR_Narrow" + ], + "dtype": "int16", + "height": 3660, + "is_cog": true, + "nodata": -9999.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.0001 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "-9999", + "add_offset": "0.0", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "long_name": "NIR_Narrow", + "scale_factor": "0.0001", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.Fmask.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.Fmask.tif.json new file mode 100644 index 00000000..fcf65ccd --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.Fmask.tif.json @@ -0,0 +1,72 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + null + ], + "dtype": "uint8", + "height": 3660, + "is_cog": true, + "nodata": 255.0, + "offsets": [ + 0.0 + ], + "scales": [ + 1.0 + ], + "tags": { + "ACCODE": "LaSRC v3.5.1.0", + "AREA_OR_POINT": "Area", + "CLOUD_MASKING_CODE": "Fmask v5.0.1", + "DATASTRIP_ID": "S2B_OPER_MSI_L1C_DS_2BPS_20261106T011146_S20261105T235230_N05.13", + "Fmask description": "Bits are listed from the MSB (bit 7) to the LSB (bit 0): \n7-6 aerosol:\n 00 - climatology\n 01 - low\n 10 - average\n 11 - high\n5 water\n4 snow/ice\n3 cloud shadow\n2 adjacent to cloud\n1 cloud\n0 cirrus cloud\n", + "HLS_PROCESSING_TIME": "2026-11-11T18:53:04Z", + "HORIZONTAL_CS_CODE": "EPSG:32755", + "HORIZONTAL_CS_NAME": "WGS84 / UTM zone 55S", + "L1C_IMAGE_QUALITY": "NONE", + "L1_PROCESSING_TIME": "2026-11-06T01:28:33.682833Z", + "MEAN_SUN_AZIMUTH_ANGLE": "49.5220612667981", + "MEAN_SUN_ZENITH_ANGLE": "36.5469569679149", + "MEAN_VIEW_AZIMUTH_ANGLE": "103.740345709356", + "MEAN_VIEW_ZENITH_ANGLE": "9.60581085171472", + "MSI band 01 bandpass adjustment slope and offset": "0.995900, -0.000200", + "MSI band 02 bandpass adjustment slope and offset": "0.977800, -0.004000", + "MSI band 03 bandpass adjustment slope and offset": "1.007500, -0.000800", + "MSI band 04 bandpass adjustment slope and offset": "0.976100, 0.001000", + "MSI band 11 bandpass adjustment slope and offset": "1.000000, -0.000300", + "MSI band 12 bandpass adjustment slope and offset": "0.986700, 0.000400", + "MSI band 8a bandpass adjustment slope and offset": "0.996600, 0.000000", + "NBAR_SOLAR_ZENITH": "37.4342904028636", + "NCOLS": "3660", + "NROWS": "3660", + "OVERVIEW_RESAMPLING": "NEAREST", + "PROCESSING_BASELINE": "05.13", + "PRODUCT_URI": "S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE", + "SENSING_TIME": "2026-11-05T23:58:59.381115Z", + "SPACECRAFT_NAME": "Sentinel-2B", + "SPATIAL_RESOLUTION": "30", + "TILE_ID": "S2B_OPER_MSI_L1C_TL_2BPS_20261106T011146_A050492_T55GEM_N05.13", + "ULX": "499980", + "ULY": "-4800000", + "_FillValue": "255", + "arop_ave_xshift(meters)": "0", + "arop_ave_yshift(meters)": "0", + "arop_ncp": "0", + "arop_rmse(meters)": "0", + "arop_s2_refimg": "NONE", + "cloud_coverage": "100", + "spatial_coverage": "25" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.SAA.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.SAA.tif.json new file mode 100644 index 00000000..ead7d9a3 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.SAA.tif.json @@ -0,0 +1,39 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + null + ], + "dtype": "uint16", + "height": 3660, + "is_cog": true, + "nodata": 40000.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.01 + ], + "tags": { + "AREA_OR_POINT": "Area", + "OVERVIEW_RESAMPLING": "NEAREST", + "ULX": "499980", + "ULY": "5200000", + "UTM_ZONE_HEMISPHERE": "55S", + "_FillValue": "40000", + "add_offset": "0.0", + "scale_factor": "0.01" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.SZA.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.SZA.tif.json new file mode 100644 index 00000000..ead7d9a3 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.SZA.tif.json @@ -0,0 +1,39 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + null + ], + "dtype": "uint16", + "height": 3660, + "is_cog": true, + "nodata": 40000.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.01 + ], + "tags": { + "AREA_OR_POINT": "Area", + "OVERVIEW_RESAMPLING": "NEAREST", + "ULX": "499980", + "ULY": "5200000", + "UTM_ZONE_HEMISPHERE": "55S", + "_FillValue": "40000", + "add_offset": "0.0", + "scale_factor": "0.01" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.VAA.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.VAA.tif.json new file mode 100644 index 00000000..ead7d9a3 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.VAA.tif.json @@ -0,0 +1,39 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + null + ], + "dtype": "uint16", + "height": 3660, + "is_cog": true, + "nodata": 40000.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.01 + ], + "tags": { + "AREA_OR_POINT": "Area", + "OVERVIEW_RESAMPLING": "NEAREST", + "ULX": "499980", + "ULY": "5200000", + "UTM_ZONE_HEMISPHERE": "55S", + "_FillValue": "40000", + "add_offset": "0.0", + "scale_factor": "0.01" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.VZA.tif.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.VZA.tif.json new file mode 100644 index 00000000..ead7d9a3 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.VZA.tif.json @@ -0,0 +1,39 @@ +{ + "band_tags": [ + {} + ], + "count": 1, + "crs": "EPSG:32655", + "descriptions": [ + null + ], + "dtype": "uint16", + "height": 3660, + "is_cog": true, + "nodata": 40000.0, + "offsets": [ + 0.0 + ], + "scales": [ + 0.01 + ], + "tags": { + "AREA_OR_POINT": "Area", + "OVERVIEW_RESAMPLING": "NEAREST", + "ULX": "499980", + "ULY": "5200000", + "UTM_ZONE_HEMISPHERE": "55S", + "_FillValue": "40000", + "add_offset": "0.0", + "scale_factor": "0.01" + }, + "transform": [ + 30.0, + 0.0, + 499980.0, + 0.0, + -30.0, + -4800000.0 + ], + "width": 3660 +} diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.cmr.xml b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.cmr.xml new file mode 100644 index 00000000..2d86f7f5 --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.cmr.xml @@ -0,0 +1,488 @@ + + + HLS.S30.T55GEM.2026309T235229.v2.0 + 2026-11-11T18:53:29.983974Z + 2026-11-11T18:53:29.983985Z + + HLS Sentinel-2 Multi-spectral Instrument Surface Reflectance Daily Global 30m v2.0 + + + 65550378 + HLS.S30.T55GEM.2026309T235229.v2.0 + DAY + 2026-11-11T18:53:04.000000Z + 2.0 + + + + 2026-11-05T23:58:59.381110Z + 2026-11-05T23:58:59.381110Z + + + + + + + + + 147.7756346 + -43.35076557 + + + 148.35444733 + -43.34483482 + + + 148.36678133 + -43.9243631 + + + 147.60307518 + -43.79156892 + + + + + + + + + Sentinel-2B + + + Sentinel-2 MSI + + + + + + + PRODUCT_URI + + S2B_MSIL1C_20261105T235229_N0513_R130_T55GEM_20261106T011146.SAFE + + + + CLOUD_COVERAGE + + 100 + + + + MGRS_TILE_ID + + 55GEM + + + + SPATIAL_COVERAGE + + 25 + + + + SPATIAL_RESOLUTION + + 30.0 + + + + HLS_PROCESSING_TIME + + 2026-11-11T18:53:04Z + + + + SENSING_TIME + + 2026-11-05T23:58:59.381115Z + + + + HORIZONTAL_CS_CODE + + EPSG:32755 + + + + HORIZONTAL_CS_NAME + + WGS84 / UTM zone 55S + + + + ULX + + 499980.0 + + + + ULY + + -4800000.0 + + + + SPATIAL_RESAMPLING_ALG + + Area Weighted Average + + + + ADD_OFFSET + + 0 + + + + REF_SCALE_FACTOR + + 0.0001 + + + + ANG_SCALE_FACTOR + + 0.01 + + + + FILLVALUE + + -9999 + + + + QA_FILLVALUE + + 255 + + + + NCOLS + + 3660 + + + + NROWS + + 3660 + + + + MEAN_SUN_AZIMUTH_ANGLE + + 49.52206127 + + + + MEAN_SUN_ZENITH_ANGLE + + 36.54695697 + + + + MEAN_VIEW_AZIMUTH_ANGLE + + 103.74034571 + + + + MEAN_VIEW_ZENITH_ANGLE + + 9.60581085 + + + + NBAR_SOLAR_ZENITH + + 37.4342904 + + + + MSI_BAND_01_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET + + 0.9959 + -0.0002 + + + + MSI_BAND_02_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET + + 0.9778 + -0.004 + + + + MSI_BAND_03_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET + + 1.0075 + -0.0008 + + + + MSI_BAND_04_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET + + 0.9761 + 0.001 + + + + MSI_BAND_11_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET + + 1.0 + -0.0003 + + + + MSI_BAND_12_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET + + 0.9867 + 0.0004 + + + + MSI_BAND_8A_BANDPASS_ADJUSTMENT_SLOPE_AND_OFFSET + + 0.9966 + 0.0 + + + + AROP_AVE_XSHIFT(METERS) + + 0.0 + + + + AROP_AVE_YSHIFT(METERS) + + 0.0 + + + + AROP_NCP + + 0 + + + + AROP_RMSE(METERS) + + 0.0 + + + + AROP_S2_REFIMG + + NONE + + + + ACCODE + + LaSRC v3.5.1.0 + + + + CLOUD_MASKING_CODE + + Fmask v5.0.1 + + + + PROCESSING_BASELINE + + 05.13 + + + + IDENTIFIER_PRODUCT_DOI + + 10.5067/HLS/HLSS30.002 + + + + IDENTIFIER_PRODUCT_DOI_AUTHORITY + + https://doi.org + + + + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B12.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B12.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B02.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B02.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.SAA.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.SAA.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.SZA.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.SZA.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B8A.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B8A.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B01.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B01.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B03.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B03.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B11.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B11.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B08.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B08.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B07.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B07.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B05.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B05.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B06.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B06.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.VAA.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.VAA.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.Fmask.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.Fmask.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B10.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B10.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.VZA.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.VZA.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B04.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B04.tif + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B09.tif + Download HLS.S30.T55GEM.2026309T235229.v2.0.B09.tif + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B12.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B02.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.SAA.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.SZA.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B8A.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B01.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B03.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B11.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B08.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B07.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B05.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B06.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.VAA.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.Fmask.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B10.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.VZA.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B04.tif + This link provides direct download access via S3 to the granule + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B09.tif + This link provides direct download access via S3 to the granule + + + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-public/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0_stac.json + Download HLS.S30.T55GEM.2026309T235229.v2.0_stac.json + EXTENDED METADATA + + + https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.cmr.xml + Download HLS.S30.T55GEM.2026309T235229.v2.0.cmr.xml + EXTENDED METADATA + + + s3://lp-prod-public/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0_stac.json + This link provides direct download access via S3 to the granule + EXTENDED METADATA + + + s3://lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.cmr.xml + This link provides direct download access via S3 to the granule + EXTENDED METADATA + + + https://data.lpdaac.earthdatacloud.nasa.gov/s3credentials + api endpoint to retrieve temporary credentials valid for same-region direct s3 access + VIEW RELATED INFORMATION + + + Cloud Optimized GeoTIFF (COG) + + diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.jpg b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0.jpg new file mode 100644 index 00000000..e69de29b diff --git a/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0_stac.json b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0_stac.json new file mode 100644 index 00000000..10307b4c --- /dev/null +++ b/src/hls-metadata-validation/tests/data/S30/HLS.S30.T55GEM.2026309T235229.v2.0_stac.json @@ -0,0 +1 @@ +{"type": "Feature", "stac_version": "1.0.0", "stac_extensions": ["https://stac-extensions.github.io/eo/v1.1.0/schema.json", "https://stac-extensions.github.io/projection/v1.1.0/schema.json", "https://stac-extensions.github.io/view/v1.0.0/schema.json", "https://stac-extensions.github.io/scientific/v1.0.0/schema.json", "https://stac-extensions.github.io/processing/v1.2.0/schema.json"], "id": "HLS.S30.T55GEM.2026309T235229.v2.0", "geometry": {"type": "MultiPolygon", "coordinates": [[[[147.775635, -43.350766], [148.354447, -43.344835], [148.366781, -43.924363], [147.603075, -43.791569], [147.775635, -43.350766]]]]}, "bbox": [147.603075, -43.924363, 148.366781, -43.344835], "properties": {"start_datetime": "2026-11-05T23:58:59.381110Z", "end_datetime": "2026-11-05T23:58:59.381110Z", "platform": "sentinel-2b", "instruments": ["msi"], "eo:cloud_cover": 100.0, "proj:transform": [30.0, 0.0, 499980.0, 0.0, -30.0, -4800000.0, 0.0, 0.0, 1.0], "proj:shape": [3660, 3660], "proj:epsg": 32655, "view:sun_azimuth": 49.52206127, "view:azimuth": 103.74034571, "sci:doi": "10.5067/HLS/HLSS30.002", "processing:software": {"Atmospheric Correction": "LaSRC v3.5.1.0", "Cloud Masking": "Fmask v5.0.1"}, "datetime": "2026-11-05T23:58:59.381110Z"}, "links": [{"rel": "self", "href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-public/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0_stac.json", "type": "application/json"}, {"rel": "cite-as", "href": "https://doi.org/10.5067/HLS/HLSS30.002"}], "assets": {"B01": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B01.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B01", "common_name": "coastal", "center_wavelength": 0.4439, "full_width_half_max": 0.027}], "roles": ["data"]}, "B02": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B02.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B02", "common_name": "blue", "center_wavelength": 0.4966, "full_width_half_max": 0.098}], "roles": ["data"]}, "B03": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B03.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B03", "common_name": "green", "center_wavelength": 0.56, "full_width_half_max": 0.045}], "roles": ["data"]}, "B04": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B04.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B04", "common_name": "red", "center_wavelength": 0.6645, "full_width_half_max": 0.038}], "roles": ["data"]}, "B05": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B05.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B05", "center_wavelength": 0.7039, "full_width_half_max": 0.019}], "roles": ["data"]}, "B06": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B06.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B06", "center_wavelength": 0.7402, "full_width_half_max": 0.018}], "roles": ["data"]}, "B07": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B07.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B07", "center_wavelength": 0.7825, "full_width_half_max": 0.028}], "roles": ["data"]}, "B08": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B08.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B08", "common_name": "nir", "center_wavelength": 0.8351, "full_width_half_max": 0.145}], "roles": ["data"]}, "B8A": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B8A.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B8A", "center_wavelength": 0.8648, "full_width_half_max": 0.033}], "roles": ["data"]}, "B09": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B09.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B09", "center_wavelength": 0.945, "full_width_half_max": 0.026}], "roles": ["data"]}, "B10": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B10.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B10", "common_name": "cirrus", "center_wavelength": 1.3735, "full_width_half_max": 0.075}], "roles": ["data"]}, "B11": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B11.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B11", "common_name": "swir16", "center_wavelength": 1.6137, "full_width_half_max": 0.143}], "roles": ["data"]}, "B12": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.B12.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "B12", "common_name": "swir22", "center_wavelength": 2.22024, "full_width_half_max": 0.242}], "roles": ["data"]}, "Fmask": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.Fmask.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "Fmask"}], "roles": ["data"]}, "SZA": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.SZA.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "SZA"}], "roles": ["data"]}, "SAA": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.SAA.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "SAA"}], "roles": ["data"]}, "VZA": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.VZA.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "VZA"}], "roles": ["data"]}, "VAA": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.VAA.tif", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "eo:bands": [{"name": "VAA"}], "roles": ["data"]}, "thumbnail": {"href": "https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-public/HLSS30.020/HLS.S30.T55GEM.2026309T235229.v2.0/HLS.S30.T55GEM.2026309T235229.v2.0.jpg", "type": "image/jpeg", "roles": ["thumbnail"]}}} \ No newline at end of file diff --git a/src/hls-metadata-validation/tests/fixtures.py b/src/hls-metadata-validation/tests/fixtures.py new file mode 100644 index 00000000..79567587 --- /dev/null +++ b/src/hls-metadata-validation/tests/fixtures.py @@ -0,0 +1,25 @@ +"""Access to the real L30/S30 fixture granules under ``tests/data``.""" + +import json +import shutil +from pathlib import Path +from typing import Any + +DATA = Path(__file__).parent / "data" +L30_GRANULE = "HLS.L30.T56NQK.2026309T235813.v2.0" +S30_GRANULE = "HLS.S30.T55GEM.2026309T235229.v2.0" +GRANULES = {"L30": L30_GRANULE, "S30": S30_GRANULE} + + +def copy_granule(product: str, root: Path) -> Path: + """Copy a real fixture granule into ``root//``; return that dir.""" + dest = root / GRANULES[product] + shutil.copytree(DATA / product, dest) + return dest + + +def edit_json(path: Path, **updates: Any) -> None: + """Overwrite top-level keys of a JSON file.""" + doc = json.loads(path.read_text()) + doc.update(updates) + path.write_text(json.dumps(doc)) diff --git a/src/hls-metadata-validation/tests/test_cmr.py b/src/hls-metadata-validation/tests/test_cmr.py new file mode 100644 index 00000000..28135305 --- /dev/null +++ b/src/hls-metadata-validation/tests/test_cmr.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from hls_metadata_validation.cmr import check_cmr, normalize_cmr +from hls_metadata_validation.spec import ProductSpec + + +def test_missing_empty_and_wrong_values_fail( + l30: ProductSpec, l30_cmr_path: Path +) -> None: + doc = normalize_cmr(l30_cmr_path) + attrs = doc["additional_attributes"] + del attrs["ACCODE"] + attrs["FILLVALUE"] = [] + attrs["NCOLS"] = ["abc"] + attrs["ANG_SCALE_FACTOR"] = ["1.0"] + attrs["REF_SCALE_FACTOR"] = ["0.000100"] + found = {(f.check, f.target) for f in check_cmr(doc, l30, granule="G")} + assert found == { + ("attr.required", "ACCODE"), + ("attr.required", "FILLVALUE"), + ("attr.type", "NCOLS"), + ("attr.value", "ANG_SCALE_FACTOR"), + } diff --git a/src/hls-metadata-validation/tests/test_golden.py b/src/hls-metadata-validation/tests/test_golden.py new file mode 100644 index 00000000..46f0fcef --- /dev/null +++ b/src/hls-metadata-validation/tests/test_golden.py @@ -0,0 +1,132 @@ +import dataclasses +import json +import shutil +from pathlib import Path + +import pytest +from fixtures import L30_GRANULE, S30_GRANULE, copy_granule, edit_json + +from hls_metadata_validation.golden import GoldenRules, compare_docs, golden_dir +from hls_metadata_validation.spec import KnownGap, ProductSpec, load_spec + + +@pytest.fixture +def rules(l30: ProductSpec) -> GoldenRules: + return GoldenRules(l30) + + +def _ref_copy(new: Path, tmp_path: Path) -> Path: + ref = tmp_path / "ref" + shutil.copytree(new, ref) + return ref + + +def _stac(root: Path) -> Path: + return root / S30_GRANULE / f"{S30_GRANULE}_stac.json" + + +def test_extra_key_fails(rules: GoldenRules) -> None: + fs = compare_docs({"a": 1, "b": 2}, {"a": 1}, path="x", rules=rules) + assert [(f.check, f.severity) for f in fs] == [("key.extra", "error")] + + +def test_approx_within_absolute_tolerance(rules: GoldenRules) -> None: + path = "cmr.additional_attributes.CLOUD_COVERAGE" + assert compare_docs(["10.4"], ["10"], path=path, rules=rules) == [] + assert [f.check for f in compare_docs(["12"], ["10"], path=path, rules=rules)] == [ + "value" + ] + + +def test_volatile_only_presence(rules: GoldenRules) -> None: + assert ( + compare_docs( + "2026-01-01", "2025-01-01", path="SZA.tags.HLS_PROCESSING_TIME", rules=rules + ) + == [] + ) + assert ( + compare_docs("v2", "v1", path="cmr.additional_attributes.ACCODE", rules=rules) + == [] + ) + assert ( + compare_docs( + "a", + "b", + path="stac.properties.processing:software.Atmospheric Correction", + rules=rules, + ) + == [] + ) + + +def test_identical_dirs_pass(s30_dir: Path, tmp_path: Path) -> None: + r = golden_dir(s30_dir, _ref_copy(s30_dir, tmp_path)) + assert r.ok, r.to_text() + + +def test_reference_granule_missing_is_skipped_warning( + two_granule_dir: Path, tmp_path: Path +) -> None: + ref = tmp_path / "ref" + copy_granule("S30", ref) + r = golden_dir(two_granule_dir, ref) + skipped = [f for f in r.findings if f.check == "reference.skipped"] + assert [(f.granule, f.severity) for f in skipped] == [(L30_GRANULE, "warning")] + + +def test_known_gap_downgrades_golden_difference( + s30_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + ref = _ref_copy(s30_dir, tmp_path) + edit_json(ref / S30_GRANULE / f"{S30_GRANULE}.SZA.tif.json", nodata=None) + spec = load_spec("S30") + gap = KnownGap( + id="test-gap", surface="golden", check="value", target="SZA", reason="test" + ) + patched = dataclasses.replace(spec, known_gaps=(gap,)) + monkeypatch.setattr("hls_metadata_validation.golden.load_spec", lambda _: patched) + r = golden_dir(s30_dir, ref) + assert r.ok, r.to_text() + assert [f.known_gap for f in r.findings if f.surface == "golden"] == ["test-gap"] + + +def test_non_angle_difference_fails(s30_dir: Path, tmp_path: Path) -> None: + ref = _ref_copy(s30_dir, tmp_path) + stac = _stac(ref) + doc = json.loads(stac.read_text()) + doc["properties"]["eo:cloud_cover"] += 50 + stac.write_text(json.dumps(doc)) + r = golden_dir(s30_dir, ref) + assert not r.ok + targets = {f.target for f in r.findings if f.surface == "golden"} + assert "stac.properties.eo:cloud_cover" in targets + + +def test_cmr_insert_time_is_volatile(s30_dir: Path, tmp_path: Path) -> None: + ref = _ref_copy(s30_dir, tmp_path) + cmr = ref / S30_GRANULE / f"{S30_GRANULE}.cmr.xml" + text = cmr.read_text() + assert "2026-11-11" in text + cmr.write_text( + text.replace("2026-11-11", "2020-01-01").replace( + "2026-11-11", "2020-01-01" + ) + ) + r = golden_dir(s30_dir, ref) + assert r.ok, r.to_text() + + +def test_reference_granule_without_new_counterpart_is_error( + s30_dir: Path, tmp_path: Path +) -> None: + ref = _ref_copy(s30_dir, tmp_path) + copy_granule("L30", ref) + r = golden_dir(s30_dir, ref) + assert not r.ok + assert any( + f.check == "reference.unmatched" + and f.granule == L30_GRANULE + and f.severity == "error" + for f in r.findings + ) diff --git a/src/hls-metadata-validation/tests/test_product_specs.py b/src/hls-metadata-validation/tests/test_product_specs.py new file mode 100644 index 00000000..572096a8 --- /dev/null +++ b/src/hls-metadata-validation/tests/test_product_specs.py @@ -0,0 +1,13 @@ +from importlib.resources import files +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize( + "needle", ['"nodata": 40000', '"nodata": -9999', '"scale_factor": 0.0001'] +) +def test_encoding_facts_written_once(needle: str) -> None: + root = Path(str(files("hls_metadata_validation") / "specs")) + hits = [p for p in root.rglob("*.json") if needle in p.read_text()] + assert len(hits) == 1, hits diff --git a/src/hls-metadata-validation/tests/test_runner.py b/src/hls-metadata-validation/tests/test_runner.py new file mode 100644 index 00000000..688f196f --- /dev/null +++ b/src/hls-metadata-validation/tests/test_runner.py @@ -0,0 +1,48 @@ +import json +from pathlib import Path + +from click.testing import CliRunner +from fixtures import DATA, L30_GRANULE, S30_GRANULE + +from hls_metadata_validation.cli import main +from hls_metadata_validation.runner import validate_dir + +ANGLES = ("SZA", "SAA", "VZA", "VAA") + + +def test_real_s30_passes() -> None: + r = validate_dir(DATA / "S30") + assert r.ok, r.to_text() + + +def test_real_l30_fails_only_on_angle_nodata_and_scale() -> None: + r = validate_dir(DATA / "L30") + errs = {(f.check, f.target) for f in r.findings if f.severity == "error"} + assert errs == {(c, a) for c in ("nodata", "scale") for a in ANGLES} + + +def test_findings_grouped_per_granule(two_granule_dir: Path) -> None: + r = validate_dir(two_granule_dir) + assert {f.granule for f in r.findings if f.severity == "error"} == {L30_GRANULE} + + +def test_missing_and_corrupt_files_are_errors_not_crashes(s30_dir: Path) -> None: + g = s30_dir / S30_GRANULE + (g / f"{S30_GRANULE}.VAA.tif.json").unlink() + (g / f"{S30_GRANULE}_stac.json").write_text('{"truncated": ') + (g / f"{S30_GRANULE}.B01.tif").write_bytes(b"not a tiff") + found = {(f.check, f.target) for f in validate_dir(s30_dir).findings} + assert { + ("file.required", "VAA"), + ("file.unreadable", "stac.json"), + ("file.unreadable", "B01"), + } <= found + + +def test_cli_spec_exit_codes_and_json(s30_dir: Path, tmp_path: Path) -> None: + runner = CliRunner() + assert runner.invoke(main, ["spec", str(s30_dir)]).exit_code == 0 + report = tmp_path / "r.json" + res = runner.invoke(main, ["spec", str(DATA / "L30"), "--json", str(report)]) + assert res.exit_code == 1 + assert json.loads(report.read_text())["ok"] is False diff --git a/src/hls-metadata-validation/tests/test_spec.py b/src/hls-metadata-validation/tests/test_spec.py new file mode 100644 index 00000000..9d32eb4e --- /dev/null +++ b/src/hls-metadata-validation/tests/test_spec.py @@ -0,0 +1,65 @@ +import json +from pathlib import Path +from typing import Any + +import pytest + +from hls_metadata_validation.spec import SpecError, load_spec_file + +SZA_ASSET: dict[str, Any] = { + "dtype": "uint16", + "nodata": 40000, + "scale_factor": 0.01, + "add_offset": 0.0, + "stac_asset": "SZA", + "granule_tags": False, +} + +MINIMAL_SPEC: dict[str, Any] = { + "product": "X30", + "version": "2.0", + "default_tolerance": 1e-6, + "attributes": { + "ACCODE": { + "required": True, + "type": "string", + "cog_tag": "ACCODE", + "cmr_additional_attribute": "ACCODE", + "golden": "volatile", + } + }, + "assets": {"SZA": SZA_ASSET}, + "known_gaps": [], +} + + +def _write(tmp_path: Path, doc: dict[str, Any], name: str = "X.json") -> Path: + p = tmp_path / name + p.write_text(json.dumps(doc)) + return p + + +def test_spec_unknown_field_raises(tmp_path: Path) -> None: + bad = json.loads(json.dumps(MINIMAL_SPEC)) + bad["assets"]["SZA"]["no_data"] = 0 + with pytest.raises(SpecError): + load_spec_file(_write(tmp_path, bad)) + + +def test_include_list_merges_with_inline(tmp_path: Path) -> None: + _write(tmp_path, {"SZA": SZA_ASSET}, "angles.json") + doc = { + **MINIMAL_SPEC, + "assets": { + "$include": ["angles.json"], + "B01": {"dtype": "int16", "nodata": -9999}, + }, + } + assert set(load_spec_file(_write(tmp_path, doc)).assets) == {"SZA", "B01"} + + +def test_include_duplicate_key_raises(tmp_path: Path) -> None: + _write(tmp_path, {"SZA": SZA_ASSET}, "angles.json") + doc = {**MINIMAL_SPEC, "assets": {"$include": "angles.json", "SZA": SZA_ASSET}} + with pytest.raises(SpecError): + load_spec_file(_write(tmp_path, doc)) diff --git a/src/hls-metadata-validation/tests/test_stac.py b/src/hls-metadata-validation/tests/test_stac.py new file mode 100644 index 00000000..f3ad32f7 --- /dev/null +++ b/src/hls-metadata-validation/tests/test_stac.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from hls_metadata_validation.spec import ProductSpec +from hls_metadata_validation.stac import check_stac, load_stac + + +def test_schema_and_spec_violations_fail(l30: ProductSpec, l30_stac_path: Path) -> None: + doc = load_stac(l30_stac_path) + doc["properties"]["eo:cloud_cover"] = "lots" + del doc["properties"]["processing:software"]["Cloud Masking"] + del doc["assets"]["SZA"] + found = { + (f.check, f.target) + for f in check_stac(doc, l30, granule="G") + if f.check != "asset.raster" + } + assert found == { + ("schema", "stac.json"), + ("property.required", "processing:software.Cloud Masking"), + ("asset.required", "SZA"), + } From 7caf125462c90752fd1425f72ff8b311bb9af9f3 Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Fri, 13 Nov 2026 14:37:25 -0500 Subject: [PATCH 2/3] feat: wire hls-metadata-validation into CI and keep benchmark outputs for validation --- .github/workflows/benchmark.yml | 33 ++++ .github/workflows/test.yml | 3 + pixi.lock | 178 ++++++++++++++++++ pixi.toml | 3 + pyproject.toml | 1 + .../tests/benchmarks/conftest.py | 29 ++- .../tests/benchmarks/test_sentinel.py | 16 +- 7 files changed, 260 insertions(+), 3 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 3aad1aff..0413f423 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -97,3 +97,36 @@ jobs: comment-on-alert: true alert-threshold: "150%" fail-on-alert: false + + # Re-assume the role: the benchmark run can outlast the first session. + - name: Configure AWS credentials for golden references (OIDC) + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 + with: + role-to-assume: ${{ vars.AWS_ROLE_TO_ASSUME_ARN }} + role-session-name: gha-${{ github.run_id }}-golden + aws-region: ${{ env.AWS_REGION }} + + - name: Fetch golden reference outputs + env: + GOLDEN_REFERENCE_PREFIX: ${{ vars.GOLDEN_REFERENCE_PREFIX }} + run: | + mkdir -p golden-reference + if [ -n "$GOLDEN_REFERENCE_PREFIX" ]; then + aws s3 sync --only-show-errors \ + "s3://hls-validation-datasets/${GOLDEN_REFERENCE_PREFIX}" golden-reference/ + else + echo "::warning::GOLDEN_REFERENCE_PREFIX is not set; every granule will be reported as skipped" + fi + + - name: Compare metadata against golden references + env: + IMAGE: ${{ env.ECR_REPOSITORY }}:benchmark + run: scripts/golden + + - name: Upload golden report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: golden-report + path: benchmark-output/golden-report.json + if-no-files-found: warn diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a898ce3e..07ecbe13 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,9 @@ jobs: - *shared - "src/hls-workflows/**" - "src/hls-utilities/**" + hls-metadata-validation: + - *shared + - "src/hls-metadata-validation/**" - name: List changed packages id: packages diff --git a/pixi.lock b/pixi.lock index ffa690b9..2f5ed921 100644 --- a/pixi.lock +++ b/pixi.lock @@ -318,6 +318,7 @@ environments: - conda: s3://hls-conda-channels/stable/linux-64/hls-libs-v3.5.1.2-hb0f4dca_0.conda - conda_source: fmask[078768b5] @ git+https://github.com/nasa-impact/fmask-v5?tag=v5.0.1.post3#9aacc7eec3d9733c706f169a0de3979fd640a37d - conda_source: lasrc-rs[eb31d84c] @ git+https://github.com/nasa-impact/lasrc-rs?subdirectory=conda&rev=2c5a859d71bdb8d376281b379435ed99e8e3206e#2c5a859d71bdb8d376281b379435ed99e8e3206e + - pypi: ./src/hls-metadata-validation - pypi: ./src/hls-utilities - pypi: ./src/hls-workflows - pypi: direct+https://github.com/NASA-IMPACT/hls-vi/releases/download/v1.20.2/hls_vi-1.20.2-py3-none-any.whl @@ -617,6 +618,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - conda_source: fmask[f3bd3c75] @ git+https://github.com/nasa-impact/fmask-v5?tag=v5.0.1.post3#9aacc7eec3d9733c706f169a0de3979fd640a37d - conda_source: lasrc-rs[688f3e7c] @ git+https://github.com/nasa-impact/lasrc-rs?subdirectory=conda&rev=2c5a859d71bdb8d376281b379435ed99e8e3206e#2c5a859d71bdb8d376281b379435ed99e8e3206e + - pypi: ./src/hls-metadata-validation - pypi: ./src/hls-utilities - pypi: ./src/hls-workflows - pypi: direct+https://github.com/NASA-IMPACT/hls-vi/releases/download/v1.20.2/hls_vi-1.20.2-py3-none-any.whl @@ -1057,6 +1059,7 @@ environments: - conda: s3://hls-conda-channels/stable/linux-64/hls-libs-v3.5.1.2-hb0f4dca_0.conda - conda_source: fmask[078768b5] @ git+https://github.com/nasa-impact/fmask-v5?tag=v5.0.1.post3#9aacc7eec3d9733c706f169a0de3979fd640a37d - conda_source: lasrc-rs[eb31d84c] @ git+https://github.com/nasa-impact/lasrc-rs?subdirectory=conda&rev=2c5a859d71bdb8d376281b379435ed99e8e3206e#2c5a859d71bdb8d376281b379435ed99e8e3206e + - pypi: ./src/hls-metadata-validation - pypi: ./src/hls-utilities - pypi: ./src/hls-workflows - pypi: direct+https://github.com/NASA-IMPACT/hls-vi/releases/download/v1.20.2/hls_vi-1.20.2-py3-none-any.whl @@ -1082,6 +1085,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/5b/60dc94cbf6af2fd3a3d3fae52de7e294819af2dfe7a1bea4d246beb7e0b6/pystac-1.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/8e/339bdd8865bcbbdb1a25a3c05255ba6d05dd10c8963bb706e40baf4d6b95/rio_cogeo-7.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8b/a0/4f2e3c0dc3d5cad958006f2e0307065cc8fe4fbf0393ff0b69d55ee9bbe5/types_jsonschema-4.26.0.20261006-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/9f/ad/b631f93c5d1f4c7d8bfe3f11cd6afd1256cb7b5a5b45891d56c9a8d02b21/untangle-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl @@ -1413,6 +1417,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - conda_source: fmask[f3bd3c75] @ git+https://github.com/nasa-impact/fmask-v5?tag=v5.0.1.post3#9aacc7eec3d9733c706f169a0de3979fd640a37d - conda_source: lasrc-rs[688f3e7c] @ git+https://github.com/nasa-impact/lasrc-rs?subdirectory=conda&rev=2c5a859d71bdb8d376281b379435ed99e8e3206e#2c5a859d71bdb8d376281b379435ed99e8e3206e + - pypi: ./src/hls-metadata-validation - pypi: ./src/hls-utilities - pypi: ./src/hls-workflows - pypi: direct+https://github.com/NASA-IMPACT/hls-vi/releases/download/v1.20.2/hls_vi-1.20.2-py3-none-any.whl @@ -1438,6 +1443,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/5b/60dc94cbf6af2fd3a3d3fae52de7e294819af2dfe7a1bea4d246beb7e0b6/pystac-1.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/8e/339bdd8865bcbbdb1a25a3c05255ba6d05dd10c8963bb706e40baf4d6b95/rio_cogeo-7.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8b/a0/4f2e3c0dc3d5cad958006f2e0307065cc8fe4fbf0393ff0b69d55ee9bbe5/types_jsonschema-4.26.0.20261006-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9f/ad/b631f93c5d1f4c7d8bfe3f11cd6afd1256cb7b5a5b45891d56c9a8d02b21/untangle-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/01/90cb898ee87d0a5c89c60a6a95978c70bceab4180abc8855de9771df09e1/morecantile-7.1.0-py3-none-any.whl @@ -1756,6 +1762,7 @@ environments: - conda: s3://hls-conda-channels/stable/linux-64/hls-libs-v3.5.1.2-hb0f4dca_0.conda - conda_source: fmask[078768b5] @ git+https://github.com/nasa-impact/fmask-v5?tag=v5.0.1.post3#9aacc7eec3d9733c706f169a0de3979fd640a37d - conda_source: lasrc-rs[eb31d84c] @ git+https://github.com/nasa-impact/lasrc-rs?subdirectory=conda&rev=2c5a859d71bdb8d376281b379435ed99e8e3206e#2c5a859d71bdb8d376281b379435ed99e8e3206e + - pypi: ./src/hls-metadata-validation - pypi: ./src/hls-utilities - pypi: ./src/hls-workflows - pypi: direct+https://github.com/NASA-IMPACT/hls-vi/releases/download/v1.20.2/hls_vi-1.20.2-py3-none-any.whl @@ -2032,6 +2039,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - conda_source: fmask[f3bd3c75] @ git+https://github.com/nasa-impact/fmask-v5?tag=v5.0.1.post3#9aacc7eec3d9733c706f169a0de3979fd640a37d - conda_source: lasrc-rs[688f3e7c] @ git+https://github.com/nasa-impact/lasrc-rs?subdirectory=conda&rev=2c5a859d71bdb8d376281b379435ed99e8e3206e#2c5a859d71bdb8d376281b379435ed99e8e3206e + - pypi: ./src/hls-metadata-validation - pypi: ./src/hls-utilities - pypi: ./src/hls-workflows - pypi: direct+https://github.com/NASA-IMPACT/hls-vi/releases/download/v1.20.2/hls_vi-1.20.2-py3-none-any.whl @@ -2109,42 +2117,58 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026e-h5d301ff_0.conda + - pypi: ./src/hls-metadata-validation - pypi: ./src/hls-utilities - pypi: ./src/hls-workflows + - pypi: git+https://github.com/NASA-IMPACT/hls-metadata?rev=v2.9#001441ca78cb54cdbd56a16b3ebcb75db6f67103 - pypi: git+https://github.com/USGS-EROS/espa-python-library.git?rev=v2.0.0#476a1c9d9ab52dea13629e8f236fd6604125282b - pypi: https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz - pypi: https://files.pythonhosted.org/packages/0e/ae/6546865a7e96d3920d4e6cec3e8ff88229a38737efe9dfa558981c57c609/mypy_boto3_s3-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1f/c9/e0f8e4e6e8a69e5959b06499582dca6349db6769cc7fdfb8a02a7c75a9ae/lxml_stubs-0.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/b8/15e2ce7b6bb125558c81f63c42074a768df15138248cf2e2f1de2b4894eb/moto-5.2.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3d/87/e62f55c956b583380e7d2a71705dfd431ee32dd1689d50491ba0c610fc11/affine-3.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/42/2b/a8973296de4b3b4a923310ed879f09a9d5c201d0f5cb707eb495d558fda2/mypy_boto3_logs-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/66/23/10b0f6ce12d3d85cfaf24d2e420f76e8e744970289e447d43846e51db32d/botocore_stubs-1.43.126-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/64/79d9ee1e3a29dd3f4334b193baa6f891223782dd5d33bdbeba5aedc2c245/boto3_stubs-1.44.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6d/86/ca7958de70cb0752350575e98229368a3a2f746a2942034b3364e17312bb/responses-0.26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/71/5b/60dc94cbf6af2fd3a3d3fae52de7e294819af2dfe7a1bea4d246beb7e0b6/pystac-1.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/72/b8/b864503789ef5c58597d2a7b4adbee62c26440ab1e682581fc6838df8947/pydantic-2.14.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/8e/339bdd8865bcbbdb1a25a3c05255ba6d05dd10c8963bb706e40baf4d6b95/rio_cogeo-7.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/81/68/3cdb0bc8eb5e30e21e2fadbf090c2a92cb92b92175fe9b76d2c74351cc6f/pyproj-3.8.0-cp315-cp315-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/8b/a0/4f2e3c0dc3d5cad958006f2e0307065cc8fe4fbf0393ff0b69d55ee9bbe5/types_jsonschema-4.26.0.20261006-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/01/90cb898ee87d0a5c89c60a6a95978c70bceab4180abc8855de9771df09e1/morecantile-7.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ac/5e/145a02bf2b9f66981108caf6cc9e78917e31e4caca14d1e3ccd3ff2c7293/pydantic_core-2.50.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/0f/82dd92901fdc21a3444eba19e23e0a8584046f8c7d6f9ae171e38ecd7dda/botocore-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/c2/6a9d0b3e4f55ad008891dda1f9316b077f28c22274c58869f8047f7d6b74/types_psutil-7.2.2.20261111-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/82/ae060d1bd8196b0b2b457aa1c2bb357d24037bcdf262a2f370a958967cd1/rasterio-1.5.2-cp315-cp315-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/de/0c/ea2544850a7c135e15033a5c7a8e7211136184b7ae63e2239d557c2b3019/typing_inspection-0.4.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e2/53/9c2ca547dc9fa1e9e2a0fec0096b9e25277f6864faba580ace75e0db5585/boto3-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f1/7f/55a7f6ae91c10aa58005e985d01756048b6e4ff82e731ea39003e1eeda3e/shapely-2.2.0-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f5/98/972fa03c9a6e1cc9184a28ea0354af50a6764bb172fe2d0751798a295f07/types_s3transfer-0.19.3-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda @@ -2183,38 +2207,54 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.17.4-ha19a7bb_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda + - pypi: ./src/hls-metadata-validation - pypi: ./src/hls-utilities - pypi: ./src/hls-workflows + - pypi: git+https://github.com/NASA-IMPACT/hls-metadata?rev=v2.9#001441ca78cb54cdbd56a16b3ebcb75db6f67103 - pypi: git+https://github.com/USGS-EROS/espa-python-library.git?rev=v2.0.0#476a1c9d9ab52dea13629e8f236fd6604125282b - pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz + - pypi: https://files.pythonhosted.org/packages/0b/60/2a8975ee00697cb33b17e140def38f2600323760e52eaa6423183a522f06/shapely-2.2.0-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/0e/ae/6546865a7e96d3920d4e6cec3e8ff88229a38737efe9dfa558981c57c609/mypy_boto3_s3-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1f/c9/e0f8e4e6e8a69e5959b06499582dca6349db6769cc7fdfb8a02a7c75a9ae/lxml_stubs-0.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/b8/15e2ce7b6bb125558c81f63c42074a768df15138248cf2e2f1de2b4894eb/moto-5.2.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3d/87/e62f55c956b583380e7d2a71705dfd431ee32dd1689d50491ba0c610fc11/affine-3.0.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/42/2b/a8973296de4b3b4a923310ed879f09a9d5c201d0f5cb707eb495d558fda2/mypy_boto3_logs-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/51/90/bd0a124e164f5fe776084c9731b43ab136b31281a18608e617cdb5f2be70/rasterio-1.5.2.tar.gz - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/66/23/10b0f6ce12d3d85cfaf24d2e420f76e8e744970289e447d43846e51db32d/botocore_stubs-1.43.126-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/64/79d9ee1e3a29dd3f4334b193baa6f891223782dd5d33bdbeba5aedc2c245/boto3_stubs-1.44.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6d/86/ca7958de70cb0752350575e98229368a3a2f746a2942034b3364e17312bb/responses-0.26.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6f/02/65951efadbf7826e6a270843f97e3da2ef726de2b6d9cd7c5dfa4c48807e/pydantic_core-2.50.4-cp315-cp315-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/71/5b/60dc94cbf6af2fd3a3d3fae52de7e294819af2dfe7a1bea4d246beb7e0b6/pystac-1.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/72/b8/b864503789ef5c58597d2a7b4adbee62c26440ab1e682581fc6838df8947/pydantic-2.14.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/8e/339bdd8865bcbbdb1a25a3c05255ba6d05dd10c8963bb706e40baf4d6b95/rio_cogeo-7.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8b/a0/4f2e3c0dc3d5cad958006f2e0307065cc8fe4fbf0393ff0b69d55ee9bbe5/types_jsonschema-4.26.0.20261006-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a7/01/90cb898ee87d0a5c89c60a6a95978c70bceab4180abc8855de9771df09e1/morecantile-7.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ae/3f/ad45d03df4f84ebae5439577ee81f3999d182711e82235c8037b6528890e/rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/c1/0f/82dd92901fdc21a3444eba19e23e0a8584046f8c7d6f9ae171e38ecd7dda/botocore-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/c2/6a9d0b3e4f55ad008891dda1f9316b077f28c22274c58869f8047f7d6b74/types_psutil-7.2.2.20261111-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c8/29/6598570c90cbfc84ddefc3ccac4aa412bf51a527d72c74cc4fe64a5e6f24/pyproj-3.8.0.tar.gz - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl + - pypi: https://files.pythonhosted.org/packages/de/0c/ea2544850a7c135e15033a5c7a8e7211136184b7ae63e2239d557c2b3019/typing_inspection-0.4.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl - pypi: https://files.pythonhosted.org/packages/e2/53/9c2ca547dc9fa1e9e2a0fec0096b9e25277f6864faba580ace75e0db5585/boto3-1.44.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl @@ -13611,6 +13651,22 @@ packages: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-9_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.5.0-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026e-h5d301ff_0.conda +- pypi: ./src/hls-metadata-validation + name: hls-metadata-validation + requires_dist: + - click>=8.1 + - jsonschema>=4.20 + - lxml + - metadata-creator @ git+https://github.com/NASA-IMPACT/hls-metadata@v2.9 + - pystac[validation]>=1.10,<1.12 + - rasterio + - rio-cogeo + - lxml-stubs>=0.5.1 ; extra == 'dev' + - mypy>=1.14.0 ; extra == 'dev' + - pytest>=9.0.2 ; extra == 'dev' + - ruff>=0.9.0 ; extra == 'dev' + - types-jsonschema ; extra == 'dev' + requires_python: '>=3.13' - pypi: ./src/hls-utilities name: hls-utilities requires_dist: @@ -13709,6 +13765,26 @@ packages: - black ; extra == 'dev' - flake8 ; extra == 'test' - pytest ; extra == 'test' +- pypi: git+https://github.com/NASA-IMPACT/hls-metadata?rev=v2.9#001441ca78cb54cdbd56a16b3ebcb75db6f67103 + name: metadata-creator + version: '2.9' + requires_dist: + - click + - numpy + - pyproj + - rasterio + - shapely + - lxml + - pytest ; extra == 'test' + - pytest-cov ; extra == 'test' + - flake8 ; extra == 'test' + - isort ; extra == 'test' + - pytest ; extra == 'dev' + - black==20.8b1 ; extra == 'dev' + - flake8 ; extra == 'dev' + - isort ; extra == 'dev' + - pre-commit ; extra == 'dev' + - pre-commit-hooks ; extra == 'dev' - pypi: git+https://github.com/NASA-IMPACT/hls-metadata?tag=v2.9#001441ca78cb54cdbd56a16b3ebcb75db6f67103 name: metadata-creator version: '2.9' @@ -13767,6 +13843,13 @@ packages: version: 1.7.16 sha256: 8677671496d0d38e66c7179f82a7e9059f94887777955dc71b0ac602ee637c26 requires_python: '>=3.6' +- pypi: https://files.pythonhosted.org/packages/0b/60/2a8975ee00697cb33b17e140def38f2600323760e52eaa6423183a522f06/shapely-2.2.0-cp315-cp315-macosx_11_0_arm64.whl + name: shapely + version: 2.2.0 + sha256: 556f20346a7d96fefbb71b74640d84ca14041703d60f0d2ff47b29d9b3e0093d + requires_dist: + - numpy>=1.26 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl name: cffi version: 2.1.1 @@ -13816,6 +13899,15 @@ packages: requires_dist: - typing-extensions>=4.16.0 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl + name: referencing + version: 0.37.0 + sha256: 381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 + requires_dist: + - attrs>=22.2.0 + - rpds-py>=0.7.0 + - typing-extensions>=4.4.0 ; python_full_version < '3.13' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl name: cryptography version: 50.0.2 @@ -13949,6 +14041,13 @@ packages: version: 1.7.0 sha256: 7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl + name: jsonschema-specifications + version: 2025.9.1 + sha256: 98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe + requires_dist: + - referencing>=0.31.0 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl name: librt version: 0.16.0 @@ -14037,6 +14136,11 @@ packages: version: 2.3.1 sha256: 9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: rpds-py + version: 2026.9.1 + sha256: 2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl name: idna version: '3.20' @@ -14954,6 +15058,33 @@ packages: - mypy-boto3-workspaces-web>=1.44.0,<1.45.0 ; extra == 'workspaces-web' - mypy-boto3-xray>=1.44.0,<1.45.0 ; extra == 'xray' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl + name: jsonschema + version: 4.26.0 + sha256: d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + requires_dist: + - attrs>=22.2.0 + - jsonschema-specifications>=2023.3.6 + - referencing>=0.28.4 + - rpds-py>=0.25.0 + - fqdn ; extra == 'format' + - idna ; extra == 'format' + - isoduration ; extra == 'format' + - jsonpointer>1.13 ; extra == 'format' + - rfc3339-validator ; extra == 'format' + - rfc3987 ; extra == 'format' + - uri-template ; extra == 'format' + - webcolors>=1.11 ; extra == 'format' + - fqdn ; extra == 'format-nongpl' + - idna ; extra == 'format-nongpl' + - isoduration ; extra == 'format-nongpl' + - jsonpointer>1.13 ; extra == 'format-nongpl' + - rfc3339-validator ; extra == 'format-nongpl' + - rfc3986-validator>0.1.0 ; extra == 'format-nongpl' + - rfc3987-syntax>=1.1.0 ; extra == 'format-nongpl' + - uri-template ; extra == 'format-nongpl' + - webcolors>=24.6.0 ; extra == 'format-nongpl' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/6a/65/cd19bc06b67d2df4828220245fd98569ba40fac00c60ba48bef1817d8ac3/mypy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl name: mypy version: 2.4.1 @@ -14992,6 +15123,13 @@ packages: - tomli ; python_full_version < '3.11' and extra == 'tests' - tomli-w ; extra == 'tests' requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/6f/02/65951efadbf7826e6a270843f97e3da2ef726de2b6d9cd7c5dfa4c48807e/pydantic_core-2.50.4-cp315-cp315-macosx_11_0_arm64.whl + name: pydantic-core + version: 2.50.4 + sha256: 930ad463d3fb7a340d323bcc496ec32dae469ec9974c29ff569f19806b6b03b4 + requires_dist: + - typing-extensions>=4.16.0 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl name: lxml version: 6.1.3 @@ -15086,6 +15224,13 @@ packages: - wheel ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test' - wmi ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test' requires_python: '>=3.6' +- pypi: https://files.pythonhosted.org/packages/81/68/3cdb0bc8eb5e30e21e2fadbf090c2a92cb92b92175fe9b76d2c74351cc6f/pyproj-3.8.0-cp315-cp315-manylinux_2_28_x86_64.whl + name: pyproj + version: 3.8.0 + sha256: 0fb11c0d6a7caa396275012a9bda461ddf2d209cf6edfe53c477fb9c71778672 + requires_dist: + - certifi + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl name: markupsafe version: 3.0.4 @@ -15112,6 +15257,13 @@ packages: - pytest-enabler>=3.4 ; extra == 'enabler' - pytest-mypy>=1.0.1 ; platform_python_implementation != 'PyPy' and extra == 'type' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/8b/a0/4f2e3c0dc3d5cad958006f2e0307065cc8fe4fbf0393ff0b69d55ee9bbe5/types_jsonschema-4.26.0.20261006-py3-none-any.whl + name: types-jsonschema + version: 4.26.0.20261006 + sha256: 29301f4e65e3928540cdf5e23ad716e38bb0c0b416a48dada213edd3d70206ec + requires_dist: + - referencing + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/90/a9/fc89a632331882c4cb91b949003418e6f5085ddcf2c9b875d499682bca91/pydantic_core-2.50.4-cp313-cp313-macosx_11_0_arm64.whl name: pydantic-core version: 2.50.4 @@ -15223,6 +15375,18 @@ packages: version: 3.3.0 sha256: a2d885187eeaa8b357600b3fcc9d963cb4300d1694196636dbd7eddc82fd0825 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ac/5e/145a02bf2b9f66981108caf6cc9e78917e31e4caca14d1e3ccd3ff2c7293/pydantic_core-2.50.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: pydantic-core + version: 2.50.4 + sha256: 26cb012c4b8ca9f847bd0ac15978bd3d0f55b050bfbe6c47355edfc7e945029b + requires_dist: + - typing-extensions>=4.16.0 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ae/3f/ad45d03df4f84ebae5439577ee81f3999d182711e82235c8037b6528890e/rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl + name: rpds-py + version: 2026.9.1 + sha256: 00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/af/1b/62874f14c7873f27c7887d2b1ea41de990e14ba9518801ffe8eacb5d51a6/ruff-0.17.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: ruff version: 0.17.4 @@ -15312,6 +15476,13 @@ packages: version: 0.13.1 sha256: 9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226 requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/c8/29/6598570c90cbfc84ddefc3ccac4aa412bf51a527d72c74cc4fe64a5e6f24/pyproj-3.8.0.tar.gz + name: pyproj + version: 3.8.0 + sha256: efa59725bba68bf97fa808b61302df32934acdceb6a5c92a8dd0e71dc266a876 + requires_dist: + - certifi + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/c9/33/a7cbfccc39056a5cf8126b7aab4c8bafbedd4f0ca68ae40ecb627a2d2cd3/py_partiql_parser-0.6.3-py2.py3-none-any.whl name: py-partiql-parser version: 0.6.3 @@ -15408,6 +15579,13 @@ packages: requires_dist: - six>=1.5 requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' +- pypi: https://files.pythonhosted.org/packages/f1/7f/55a7f6ae91c10aa58005e985d01756048b6e4ff82e731ea39003e1eeda3e/shapely-2.2.0-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl + name: shapely + version: 2.2.0 + sha256: fdb599ec540cea5b635ac47bf24fca4cdfd1c39730ffc0b6cf0d2666b0dd9a33 + requires_dist: + - numpy>=1.26 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl name: pathspec version: 1.1.1 diff --git a/pixi.toml b/pixi.toml index 3a6d7d88..c49eb0b4 100644 --- a/pixi.toml +++ b/pixi.toml @@ -15,6 +15,7 @@ hls-libs = "=v3.5.1.2" [pypi-dependencies] hls-utilities = { path = "./src/hls-utilities", editable = false } hls-workflows = { path = "./src/hls-workflows", editable = false} +hls-metadata-validation = { path = "./src/hls-metadata-validation", editable = false } # Remote repositories hls-browse-imagery-creator = { git = "https://github.com/NASA-IMPACT/hls-browse_imagery", tag = "v1.9"} hls-cmr_stac = { git = "https://github.com/NASA-IMPACT/hls-cmr_stac", tag = "v1.9" } @@ -67,11 +68,13 @@ py-spy = "*" [feature.dev.pypi-dependencies] hls-utilities = { path = "./src/hls-utilities", editable = true, extras = ["dev"] } hls-workflows = { path = "./src/hls-workflows", editable = true, extras = ["dev"] } +hls-metadata-validation = { path = "./src/hls-metadata-validation", editable = true, extras = ["dev"] } # Type stubs for boto3 clients (e.g. CloudWatchLogsClient in scripts/plot_metrics.py) boto3-stubs = { version = ">=1", extras = ["logs"] } [feature.benchmark.pypi-dependencies] hls-workflows = { path = "./src/hls-workflows", editable = true, extras = ["dev"] } +hls-metadata-validation = { path = "./src/hls-metadata-validation", editable = true } [environments] diff --git a/pyproject.toml b/pyproject.toml index bdaca44f..dc687469 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ ignore = ["E501", "E731"] known-first-party = [ "hls_utilities", "hls_browse_imagery", + "hls_metadata_validation", ] [tool.mypy] diff --git a/src/hls-workflows/tests/benchmarks/conftest.py b/src/hls-workflows/tests/benchmarks/conftest.py index 0a154485..244677d9 100644 --- a/src/hls-workflows/tests/benchmarks/conftest.py +++ b/src/hls-workflows/tests/benchmarks/conftest.py @@ -3,6 +3,7 @@ import json import logging import os +import re import shutil import tempfile from collections.abc import Iterator @@ -71,6 +72,32 @@ def from_env(cls) -> BenchmarkConfig: ) +FINAL_PRODUCT_RE = re.compile( + r"^(?PHLS\.[LS]30\.T\w{5}\.\d{7}T\d{6}\.v\d+\.\d+)" + r"(\.[A-Za-z0-9]+\.tif|\.cmr\.xml|_stac\.json|\.jpg)$" +) + + +def benchmark_output_dir() -> Path: + return Path(os.environ.get("BENCHMARK_OUTPUT_DIR", "benchmark-output")) + + +def keep_products(work_dir: Path, out_root: Path) -> list[Path]: + """Copy final HLS products under ``work_dir`` to ``out_root//``. + + Intermediates (e.g. ``.hdf``) and VI outputs are skipped. + """ + copied = [] + for path in sorted(work_dir.rglob("*")): + m = FINAL_PRODUCT_RE.match(path.name) + if m is None or not path.is_file(): + continue + dest = out_root / m["granule"] + dest.mkdir(parents=True, exist_ok=True) + copied.append(Path(shutil.copy2(path, dest / path.name))) + return copied + + def _split_ids(var: str) -> list[str]: return [g for g in os.environ.get(var, "").split(",") if g] @@ -267,7 +294,7 @@ def write(self) -> None: } ) - out_dir = Path(os.environ.get("BENCHMARK_OUTPUT_DIR", "benchmark-output")) + out_dir = benchmark_output_dir() out_dir.mkdir(parents=True, exist_ok=True) out = out_dir / "resources.json" out.write_text(json.dumps(entries, indent=2)) diff --git a/src/hls-workflows/tests/benchmarks/test_sentinel.py b/src/hls-workflows/tests/benchmarks/test_sentinel.py index 1a974c9e..e4683810 100644 --- a/src/hls-workflows/tests/benchmarks/test_sentinel.py +++ b/src/hls-workflows/tests/benchmarks/test_sentinel.py @@ -8,7 +8,13 @@ from hls_workflows.metrics import InMemorySink from hls_workflows.sentinel.workflow import construct_pipeline -from .conftest import BenchmarkConfig, ResourceMetrics, granule_params +from .conftest import ( + BenchmarkConfig, + ResourceMetrics, + benchmark_output_dir, + granule_params, + keep_products, +) @pytest.mark.parametrize( @@ -34,6 +40,7 @@ def test_s30( local_granule_zips=[s2_local_zips[granule_id]], upload=False, metric_sink=sink, + cleanup_working_dir=False, ) # Runtime/memory/CPU come from the pipeline's own sampler (the InMemorySink). @@ -41,7 +48,12 @@ def test_s30( with pipeline.metrics.collect_pipeline( pipeline_class="Pipeline", pipeline_name="sentinel-ac" ): - pipeline.run() + context = pipeline.run() # "(v5)" suffix kept literal to preserve the benchmark chart series key. resource_metrics.add(f"{granule_id} (v5)", sink.records) + products = keep_products(work_dir, benchmark_output_dir() / "products") + assert products, ( + f"no final products for {granule_id} (exit_code={context.exit_code}); " + "the golden comparison would silently miss this granule" + ) From f4f6dbf280b9aa791bd10ccbbac19aafa88f444e Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Fri, 13 Nov 2026 14:37:40 -0500 Subject: [PATCH 3/3] docs: golden metadata validation --- docs/benchmarks.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 11364522..bef62fe8 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -66,6 +66,7 @@ configured in the repository's **Settings → Secrets and variables → Actions* | `BENCHMARK_AUX_PREFIX` | S3 key prefix for LaSRC ancillary data | `lasrc_aux` | | `BENCHMARK_S2_GRANULE_IDS` | Comma-separated Sentinel-2 granule IDs to benchmark | see below | | `BENCHMARK_LS_GRANULE_IDS` | Comma-separated Landsat granule IDs to benchmark | see below | +| `GOLDEN_REFERENCE_PREFIX` | Key prefix in `hls-validation-datasets` holding golden reference outputs (optional) | `golden/v2.0` | ### Thread pinning @@ -95,6 +96,37 @@ s3://{BENCHMARK_AUX_BUCKET}/ ... ``` +## Golden metadata comparison + +After the benchmarks are published, the workflow compares the metadata of the +products the S30 benchmark produced against reference production outputs, using +`hls-validate-metadata golden` (`src/hls-metadata-validation`). The S30 +benchmark keeps its final products (band/Fmask/angle COGs, `.cmr.xml`, +`_stac.json`, `.jpg`) in `$BENCHMARK_OUTPUT_DIR/products//`; +`scripts/golden` then runs the comparison inside the benchmark image and writes +`golden-report.json`, uploaded as the `golden-report` workflow artifact. A +failing comparison fails the workflow after the charts are published. + +References are synced from `s3://hls-validation-datasets/{GOLDEN_REFERENCE_PREFIX}/` +and must use the same layout as the products directory: + +``` +s3://hls-validation-datasets/{GOLDEN_REFERENCE_PREFIX}/ + HLS.S30.T18UYA.2026027T160429.v2.0/ + HLS.S30.T18UYA.2026027T160429.v2.0.B01.tif + ... + HLS.S30.T18UYA.2026027T160429.v2.0.cmr.xml + HLS.S30.T18UYA.2026027T160429.v2.0_stac.json +``` + +`hls-validate-metadata dump` output (`.tif.json` metadata instead of full COGs) +is also accepted as a reference. Granules without a reference are reported as +`reference.skipped` warnings, so the step passes until references are uploaded. +The OIDC role needs `s3:ListBucket` and `s3:GetObject` on `hls-validation-datasets`. + +L30 products are not compared yet: the Landsat benchmark stops after +atmospheric correction and does not run the tiling pipeline. + ### One-time repo setup Enable GitHub Pages: **Settings → Pages → Source: `gh-pages` branch, root folder**. The first benchmark run will create