├── .codespellignore ├── .flake8 ├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── continuous-integration.yml │ └── release.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── CHANGELOG.MD ├── CODE_OF_CONDUCT.MD ├── CONTRIBUTING.MD ├── LICENSE ├── README.md ├── docker ├── Dockerfile ├── Dockerfile-dev ├── build ├── cibuild ├── console ├── docs-server ├── format ├── notebook ├── stac └── test ├── environment.yml ├── examples ├── catalog.json ├── sentinel2-l1c-example │ ├── S2A_T01LAC_20200717T221944_L1C │ │ └── S2A_T01LAC_20200717T221944_L1C.json │ └── collection.json └── sentinel2-l2a-example │ ├── S2A_T07HFE_20190212T192646_L2A │ └── S2A_T07HFE_20190212T192646_L2A.json │ └── collection.json ├── pyproject.toml ├── requirements-dev.txt ├── scripts ├── cibuild ├── create_examples.py ├── create_expected.py ├── format ├── lint ├── publish ├── stac ├── test └── update ├── src └── stactools │ └── sentinel2 │ ├── __init__.py │ ├── cog.py │ ├── commands.py │ ├── constants.py │ ├── granule_metadata.py │ ├── mgrs.py │ ├── product_metadata.py │ ├── safe_manifest.py │ ├── stac.py │ ├── tileinfo_metadata.py │ └── utils.py └── tests ├── __init__.py ├── data-files ├── S2A_MSIL1C_20200717T221941_R029_T01LAC_20200717T234135.SAFE │ ├── DATASTRIP │ │ └── DS_SGS__20200717T234135_S20200717T221944 │ │ │ ├── MTD_DS.xml │ │ │ └── QI_DATA │ │ │ ├── FORMAT_CORRECTNESS.xml │ │ │ ├── GENERAL_QUALITY.xml │ │ │ ├── GEOMETRIC_QUALITY.xml │ │ │ ├── RADIOMETRIC_QUALITY.xml │ │ │ └── SENSOR_QUALITY.xml │ ├── GRANULE │ │ └── L1C_T01LAC_A026481_20200717T221944 │ │ │ └── MTD_TL.xml │ ├── MTD_MSIL1C.xml │ ├── expected_output.json │ └── manifest.safe ├── S2A_MSIL1C_20210908T042701_N0301_R133_T46RER_20210908T070248.SAFE │ ├── DATASTRIP │ │ └── DS_VGS4_20210908T070248_S20210908T043714 │ │ │ └── MTD_DS.xml │ ├── GRANULE │ │ └── L1C_T46RER_A032448_20210908T043714 │ │ │ └── MTD_TL.xml │ ├── MTD_MSIL1C.xml │ ├── expected_output.json │ └── manifest.safe ├── S2A_MSIL2A_20150826T185436_N0212_R070_T11SLT_20210412T023147 │ ├── MTD_MSIL2A.xml │ └── MTD_TL.xml ├── S2A_MSIL2A_20180721T053721_N0212_R062_T43MDV_20201011T181419.SAFE │ └── MTD_MSIL2a.xml ├── S2A_MSIL2A_20190212T192651_N0212_R013_T07HFE_20201007T160857.SAFE │ ├── GRANULE │ │ └── L2A_T07HFE_A019029_20190212T192646 │ │ │ └── MTD_TL.xml │ ├── MTD_MSIL2A.xml │ ├── expected_output.json │ └── manifest.safe ├── S2A_MSIL2A_20230625T234621_N0509_R073_T01WCP_20230626T022157.SAFE │ ├── GRANULE │ │ └── L2A_T01WCP_A041826_20230625T234624 │ │ │ └── MTD_TL.xml │ ├── MTD_MSIL2A.xml │ ├── expected_output.json │ └── manifest.safe ├── S2A_MSIL2A_20230625T234621_N0509_R073_T01WCP_20230626T022158.SAFE │ ├── GRANULE │ │ └── L2A_T01WCP_A041826_20230625T234624 │ │ │ └── MTD_TL.xml │ ├── MTD_MSIL2A.xml │ ├── expected_output.json │ └── manifest.safe ├── S2A_MSIL2A_20230625T234621_N0509_R073_T01WCS_20230626T022157.SAFE │ ├── GRANULE │ │ └── L2A_T01WCS_A041826_20230625T234624 │ │ │ └── MTD_TL.xml │ ├── L2A_T01WCS_A041826_20230625T234624 │ │ └── MTD_TL.xml │ ├── MTD_MSIL2A.xml │ ├── MTD_TL.xml │ ├── expected_output.json │ └── manifest.safe ├── S2A_MSIL2A_20230821T221941_N0509_R029_T01KAB_20230822T021825.SAFE │ ├── GRANULE │ │ └── L2A_T01KAB_A042640_20230821T221944 │ │ │ └── MTD_TL.xml │ ├── MTD_MSIL2A.xml │ ├── expected_output.json │ └── manifest.safe ├── S2A_OPER_MSI_L1C_TL_SGS__20181231T203637_A018414_T10SDG │ ├── expected_output.json │ ├── metadata.xml │ └── tileInfo.json ├── S2A_OPER_MSI_L2A_DS_2APS_20230105T201055_S20230105T163809 │ ├── metadata.xml │ └── tileInfo.json ├── S2A_OPER_MSI_L2A_TL_2APS_20240108T121951_A044635_T34VEL │ ├── expected_output.json │ ├── metadata.xml │ └── tileInfo.json ├── S2A_OPER_MSI_L2A_TL_SGS__20181231T210250_A018414_T10SDG │ ├── expected_output.json │ ├── metadata.xml │ └── tileInfo.json ├── S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBP │ ├── expected_output.json │ ├── metadata.xml │ └── tileInfo.json ├── S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBQ-no-tileDataGeometry-no-product-metadata │ ├── metadata.xml │ └── tileInfo.json ├── S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBQ-no-tileDataGeometry │ ├── metadata.xml │ ├── product_metadata.xml │ └── tileInfo.json ├── S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBQ │ ├── expected_output.json │ ├── metadata.xml │ ├── product_metadata.xml │ └── tileInfo.json ├── S2A_T60CWS_20240109T203651_L2A │ ├── metadata.xml │ └── tileInfo.json ├── S2B_MSIL2A_20191228T210519_N0212_R071_T01CCV_20201003T104658.SAFE │ ├── GRANULE │ │ └── L2A_T01CCV_A014683_20191228T210521 │ │ │ └── MTD_TL.xml │ ├── MTD_MSIL2A.xml │ ├── expected_output.json │ └── manifest.safe ├── S2B_MSIL2A_20200914T231559_N0500_R087_T01VCG_20230315T224658 │ ├── metadata.xml │ └── tileInfo.json ├── S2B_MSIL2A_20220413T150759_N0400_R025_T33XWJ_20220414T082126.SAFE │ ├── GRANULE │ │ └── L2A_T33XWJ_A026649_20220413T150756 │ │ │ └── MTD_TL.xml │ ├── MTD_MSIL2A.xml │ ├── expected_output.json │ └── manifest.safe ├── S2B_OPER_MSI_L2A_DS_VGS1_20201101T095401_S20201101T074429-no-data │ ├── metadata.xml │ └── tileInfo.json └── esa_S2B_MSIL2A_20210122T133229_N0214_R081_T22HBD_20210122T155500.SAFE │ ├── GRANULE │ └── L2A_T22HBD_A020270_20210122T133224 │ │ └── MTD_TL.xml │ ├── MTD_MSIL2A.xml │ ├── expected_output.json │ └── manifest.safe ├── test_commands.py ├── test_metadata.py └── test_stac.py /.codespellignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stactools-packages/sentinel2/44c71eec0d93858182ceb8144095f4008d9f0ec9/.codespellignore -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | 4 | ## IGNORES 5 | 6 | # E127: flake8 reporting incorrect continuation line indent errors 7 | # on multi-line and multi-level indents 8 | 9 | # W503, W504: flake8 reports this as incorrect, and scripts/format_code 10 | # changes code to it, so let format_code win. 11 | 12 | ignore = E127,W503,W504 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: pip 8 | directory: "/.github/workflows" 9 | schedule: 10 | interval: weekly 11 | - package-ecosystem: pip 12 | directory: "/" 13 | schedule: 14 | interval: weekly -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Related issues 2 | 3 | - Closes #XXX 4 | - Unblocks #XXX 5 | 6 | ## Description 7 | 8 | Please describe your changes. 9 | 10 | ## Checklist 11 | 12 | - [ ] Includes tests 13 | - [ ] Includes documentation 14 | - [ ] Updates CHANGELOG 15 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | name: docker 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Execute linters and test suites 16 | run: ./docker/cibuild 17 | - name: Upload All coverage to Codecov 18 | uses: codecov/codecov-action@v5.4.3 19 | with: 20 | token: ${{ secrets.CODECOV_TOKEN }} 21 | files: ./coverage.xml 22 | fail_ci_if_error: false 23 | python-matrix: 24 | name: python-matrix 25 | runs-on: ubuntu-latest 26 | strategy: 27 | matrix: 28 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 29 | defaults: 30 | run: 31 | shell: bash -l {0} 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Set up conda cache 35 | uses: actions/cache@v4 36 | with: 37 | path: ~/conda_pkgs_dir 38 | key: ${{ runner.os }}-conda-${{ hashFiles('**/environment.yml') }} 39 | restore-keys: ${{ runner.os }}-conda- 40 | - name: Set up pip cache 41 | uses: actions/cache@v4 42 | with: 43 | path: ~/.cache/pip 44 | key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} 45 | restore-keys: ${{ runner.os }}-pip- 46 | - name: Set up pre-commit cache 47 | uses: actions/cache@v4 48 | with: 49 | path: ~/.cache/pre-commit 50 | key: ${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml' )}} 51 | restore-keys: ${{ runner.os }}-pre-commit- 52 | - name: Set up Conda with Python ${{ matrix.python-version }} 53 | uses: conda-incubator/setup-miniconda@v3 54 | with: 55 | auto-update-conda: true 56 | python-version: ${{ matrix.python-version }} 57 | - name: Update Conda's environment 58 | run: conda env update -f environment.yml -n test 59 | - name: Execute linters and test suites 60 | run: ./scripts/cibuild 61 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | release: 10 | name: release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Python 3.x 16 | uses: actions/setup-python@v5.6.0 17 | with: 18 | python-version: "3.x" 19 | 20 | - name: Install release dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install setuptools wheel twine build 24 | 25 | - name: Build and publish package 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_STACUTILS_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_STACUTILS_PASSWORD }} 29 | run: | 30 | scripts/publish 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | output 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # pytype static type analyzer 137 | .pytype/ 138 | 139 | # Cython debug symbols 140 | cython_debug/ 141 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # Configuration file for pre-commit (https://pre-commit.com/). 2 | # Please run `pre-commit run --all-files` when adding or changing entries. 3 | 4 | repos: 5 | - repo: https://github.com/codespell-project/codespell 6 | rev: v2.4.1 7 | hooks: 8 | - id: codespell 9 | args: [--ignore-words=.codespellignore] 10 | types_or: [jupyter, markdown, python, shell] 11 | - repo: https://github.com/pre-commit/mirrors-mypy 12 | rev: v1.14.1 13 | hooks: 14 | - id: mypy 15 | # TODO lint test and scripts too 16 | files: "^src/.*\\.py$" 17 | args: 18 | - --namespace-packages 19 | - --ignore-missing-imports 20 | - --explicit-package-bases 21 | additional_dependencies: 22 | - click != 8.1.0 23 | - numpy 24 | - pyproj 25 | - pystac 26 | - types-requests 27 | - repo: https://github.com/charliermarsh/ruff-pre-commit 28 | rev: v0.9.4 29 | hooks: 30 | - id: ruff 31 | - id: ruff-format 32 | -------------------------------------------------------------------------------- /CHANGELOG.MD: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project attempts to match the major and minor versions of [stactools](https://github.com/stac-utils/stactools) and increments the patch number as needed. 6 | 7 | ## [Unreleased] 8 | 9 | ### Added 10 | 11 | - Add `eo:snow_cover` for L2A 12 | 13 | ## [0.7.1] - 2025-06-03 14 | 15 | ### Added 16 | 17 | - Projection Extension to `preview` asset for SAFE-based creation, or from 18 | sentinel-hub format. 19 | 20 | ## [0.7.0] - 2025-05-28 21 | 22 | ### Added 23 | 24 | - Added `allow_fallback_geometry` option to create stac, that allows usage of product 25 | metadata geometry if data is not found in AWS tileInfo metadata 26 | 27 | ### Changed 28 | 29 | - Updated project configuration to use only `pyproject.toml` 30 | - Updated `grid.code` to include leading zeros on UTM zones less than 10. 31 | ([#188](https://github.com/stactools-packages/sentinel2/issues/188)) 32 | - Use shapely `transform` instead of `reproject_shape` reproject to EPSG:4326 33 | 34 | ## [0.6.5] - 2025-02-05 35 | 36 | ### Fix 37 | 38 | - Update extraction of `platform` property to add support for both Sentinel-2C 39 | and Sentinel-2D. 40 | 41 | ## [0.6.4] - 2024-04-04 42 | 43 | ### Added 44 | 45 | - Added a `raster:bands` field to the `visual` asset (TCI). 46 | 47 | ## [0.6.3] - 2024-02-01 48 | 49 | ### Changed 50 | 51 | - Scenes that produce a geometry that has an unreasonably large area now raise and exception 52 | rather than producing an item with that incorrect geometry. 53 | 54 | ## [0.6.2] - 2024-01-08 55 | 56 | ### Fixed 57 | 58 | - ViewExtension handles NaN values for viewing_angles correctly. 59 | 60 | ## [0.6.1] - 2024-01-04 61 | 62 | ### Fixed 63 | 64 | - if tileinfo metadata is missing tileDataGeometry field, throw a ValueError with a meaningful 65 | message instead of an unintentional KeyError 66 | 67 | ### Changed 68 | 69 | - use reproject_shape instead of reproject_geom (deprecated) 70 | 71 | ## [0.6.0] - 2023-12-13 72 | 73 | ### Fixed 74 | 75 | - Antimeridian-crossing scene bboxes 76 | 77 | ## Removed 78 | 79 | - `create_item` method parameter `create_item` removed, as it was no longer used 80 | 81 | ## [0.5.0] - 2023-12-01 82 | 83 | ### Added 84 | 85 | - Add `https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json` conformance class 86 | - Add `s2:tile_id` field 87 | - `product_metadata` asset ([#117](https://github.com/stactools-packages/sentinel2/pull/117)) 88 | - Examples ([#124](https://github.com/stactools-packages/sentinel2/pull/124)) 89 | - `cloud` and `snow` assets ([#129](https://github.com/stactools-packages/sentinel2/pull/129)) 90 | - gsd for ancillary assets (e.g., aot, wvp, etc) ([#139](https://github.com/stactools-packages/sentinel2/pull/139)) 91 | - Mean values for sensor azimuth and incidence angle in Item properties ([#137](https://github.com/stactools-packages/sentinel2/pull/141)) 92 | - Add PVI asset as "preview" for Sentinel-2 L2A ([#143](https://github.com/stactools-packages/sentinel2/pull/143)) 93 | 94 | ### Fixed 95 | 96 | - Antimeridian handling (again) ([#122](https://github.com/stactools-packages/sentinel2/pull/122)) 97 | - Populate `created` property with a valid RFC 3339 datetime ([#125](https://github.com/stactools-packages/sentinel2/pull/125)) 98 | - stactools required version should be >=0.5.2 instead of >= 0.4.8 ([#125](https://github.com/stactools-packages/sentinel2/pull/125)) 99 | - Roles fixed, 'reflectance' removed from auxiliary assets (e.g., wvp, aot) 100 | 101 | ### Changed 102 | 103 | - The convention for naming the STAC Items has changed. ([#131](https://github.com/stactools-packages/sentinel2/pull/131)). A full explanation given in [Issue #130](https://github.com/stactools-packages/sentinel2/issues/130) 104 | - pystac >= 1.9.0 is now required 105 | - Names in eo:bands structure are now S2 band names, not common name ([#139](https://github.com/stactools-packages/sentinel2/pull/139)) 106 | - PVI asset role changed from "thumbnail" or "visual" to "overview" ([#143](https://github.com/stactools-packages/sentinel2/pull/143)) 107 | - Removed asset "thumbnail" pointing to preview.jpg asset, as this file frequently 108 | does not exit. ([#144](https://github.com/stactools-packages/sentinel2/pull/144)) 109 | 110 | ### Removed 111 | 112 | - Removes `s2:granule_id` 113 | - Removes `s2:mgrs_tile` field, as this is covered by both the MRGS Extension and Grid Extension fields 114 | - Drop support for Python 3.9 115 | - raster:bands.bits_per_pixel ([#139](https://github.com/stactools-packages/sentinel2/pull/139)) 116 | - Band descriptions ([#139](https://github.com/stactools-packages/sentinel2/pull/139)) 117 | - Per asset sensor azimuth and incidence angles ([#137](https://github.com/stactools-packages/sentinel2/pull/141)) 118 | 119 | ## [0.4.2] - 2023-07-03 120 | 121 | ### Fixed 122 | 123 | - Antimeridian-crossing geometries are now valid. 124 | - Centroids of antimeridian-crossing MultiPolygons are now computed correctly. 125 | 126 | ## [0.4.1] - 2023-04-28 127 | 128 | ### Added 129 | 130 | - Projection Extension 'centroid' field 131 | 132 | ## [0.4.0] - 2023-01-31 133 | 134 | ### Changed 135 | 136 | - remove units where they are 'none' 137 | - update precision for float values 138 | - updated prefix from 'sentinel2' to 's2' 139 | - TCI asset now has role `visual` instead of `data` 140 | - Change platform and constellation to use best practices (lowercase, no spaces) 141 | - Change asset names to use underscore instead of dash 142 | - Band 8A corrected to have common name `nir08` instead of `rededge` 143 | - Asset object keys are now common name rather than band numbers (e.g., `red` instead of `B10`) 144 | - Use black (instead of yapf) for formatting 145 | - pre-commit and isort 146 | 147 | ### Added 148 | 149 | - add "reflectance" to all data asset roles 150 | - add additional sentinel2 properties 151 | - add raster extension 152 | - Populate MGRS Extension fields 153 | - Populate Grid Extension fields 154 | - Add support for AWS S3 Open Data format (produced by Sinergise) 155 | - Band 9 now has common name nir09 and Band 10 has common name cirrus 156 | 157 | ## [0.3.0] - 2022-03-22 158 | 159 | ## Changed 160 | 161 | - updated stactools dependency to version 0.3.0 162 | 163 | ## Added 164 | 165 | - Adding support for Level-1C products 166 | 167 | ## [0.2.0] - 2021-07-21 168 | 169 | ### Changed 170 | 171 | - Modified Item IDs to include product discriminator ([#7](https://github.com/stactools-packages/sentinel2/pull/7)) 172 | - Upgrade to stactools 0.2.1.a2 (supporting PySTAC 1.0.0) 173 | 174 | [Unreleased]: 175 | [0.7.1]: 176 | [0.7.0]: 177 | [0.6.5]: 178 | [0.6.4]: 179 | [0.6.3]: 180 | [0.6.2]: 181 | [0.6.1]: 182 | [0.6.0]: 183 | [0.5.0]: 184 | [0.4.2]: 185 | [0.4.1]: 186 | [0.4.0]: 187 | [0.3.0]: 188 | [0.2.0]: 189 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.MD: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Attribution 47 | 48 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 49 | available at 50 | 51 | [homepage]: 52 | 53 | For answers to common questions about this code of conduct, see 54 | 55 | -------------------------------------------------------------------------------- /CONTRIBUTING.MD: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Pull Requests are the primary method of contributing to stactools-packages. Everyone is welcome to submit a stactools-package to describe their data. The intent with this project to get data which can be described by [STAC](https://stacspec.org/) to be built in an open and interoperable manner. 4 | 5 | We consider everyone using the stactools-packages to be a 'contributor'. 6 | 7 | ## Deprecation 8 | 9 | We recommend following the [Numpy Enhancement Proposals (NEP) 29](https://numpy.org/neps/nep-0029-deprecation_policy.html) 10 | suggested deprecation policy for supported python versions. 11 | 12 | >When a project releases a new major or minor version, we recommend that they support at least all minor versions of Python introduced and released in the prior 42 months from the anticipated release date with a minimum of 2 minor versions of Python, and all minor versions of NumPy released in the prior 24 months from the anticipated release date with a minimum of 3 minor versions of NumPy. 13 | 14 | ## Submitting Pull Requests 15 | 16 | Any proposed changes to an existing stactools-packages should be done as pull requests. Please make these 17 | requests against the `main` branch (unless otherwise directed by the dataset maintainer). 18 | 19 | Creating a Pull Request will show our PR template, which includes checkbox reminders for a number 20 | of things. 21 | 22 | - Adding an entry the [CHANGELOG](CHANGELOG.md). If the change is more editorial and minor then this is not required, but any change to the actual code should have one. 23 | - Make a ticket in the stactools-package data specific repository which is tracked on the [project management board](https://github.com/orgs/stactools-packages/projects/1). 24 | - Highlight if the PR makes breaking changes to the code. 25 | 26 | 27 | --- 28 | Attribution: 29 | - https://docs.github.com/en/communities/ 30 | - https://github.com/radiantearth/stac-spec/blob/master/CONTRIBUTING.md 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is licensed under the Apache 2 license, quoted below. 2 | 3 | Copyright 2021 COMPANY [COMPANY WEBPAGE URL] 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); you may not 6 | use this file except in compliance with the License. You may obtain a copy of 7 | the License at 8 | 9 | [http://www.apache.org/licenses/LICENSE-2.0] 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | License for the specific language governing permissions and limitations under 15 | the License. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stactools-sentinel2 2 | 3 | stactools package for Sentinel-2 data. 4 | 5 | ## Examples 6 | 7 | - [L1C item](./examples/sentinel2-l1c-example/S2A_T01LAC_20200717T221944_L1C/S2A_T01LAC_20200717T221944_L1C.json) 8 | - [L2A item](./examples/sentinel2-l2a-example/S2A_T07HFE_20190212T192646_L2A/S2A_T07HFE_20190212T192646_L2A.json) 9 | 10 | ## Running 11 | 12 | ```shell 13 | pip install stactools-sentinel2 14 | ```` 15 | 16 | SAFE archive: 17 | 18 | ```shell 19 | stac sentinel2 create-item tests/data-files/S2A_MSIL2A_20190212T192651_N0212_R013_T07HFE_20201007T160857.SAFE output/ 20 | ``` 21 | 22 | AWS Open Data bucket `sentinel-s2-l2a`: 23 | 24 | ```shell 25 | stac sentinel2 create-item tests/data-files/S2A_OPER_MSI_L2A_TL_SGS__20181231T210250_A018414_T10SDG output/ 26 | ``` 27 | 28 | Sentinel Hub metadata: 29 | 30 | ```shell 31 | stac sentinel2 create-item --asset-href-prefix s3://sentinel-s2-l2a/tiles/34/L/BP/2022/4/1/0/ \ 32 | https://roda.sentinel-hub.com/sentinel-s2-l2a/tiles/34/L/BP/2022/4/1/0/ output 33 | ``` 34 | 35 | **Note:** this does not currently work with S3 buckets using requester-pays. 36 | 37 | The flag `--tolerance` can be set to a decimal value to define the simplification tolerance of the Item geometry. 38 | This is a pass-through to the [Shapely simplify method](https://shapely.readthedocs.io/en/stable/manual.html#object.simplify). 39 | 40 | ## Development 41 | 42 | Install pre-commit hooks with: 43 | 44 | ```commandline 45 | pre-commit install 46 | ``` 47 | 48 | Run these pre-commit hooks with: 49 | 50 | ```commandline 51 | pre-commit run --all-files 52 | ``` 53 | 54 | Install the code in the local python env so your IDE can see it: 55 | 56 | ```commandline 57 | pip install -e . 58 | ``` 59 | 60 | Run the tests with: 61 | 62 | ```commandline 63 | pytest -vvv 64 | ``` 65 | 66 | Many tests use an expected output fixture (named `"expected_output.json"`). If 67 | your changes require updating these, simply: 68 | 69 | 1. remove the `expected_output.json` fixture for the test you are working on 70 | 2. then re-run the test via `pytest`, which recreates the expected output 71 | 3. confirm the changes are as you expected via `git diff`. 72 | 73 | If you change the STAC metadata output, you will need to re-create the test files with the following command: 74 | 75 | ```shell 76 | python scripts/create_expected.py 77 | ``` 78 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM continuumio/miniconda3 2 | 3 | RUN conda update conda && conda install pip 4 | 5 | COPY environment.yml /tmp/environment.yml 6 | RUN conda env update -f /tmp/environment.yml -n base && rm /tmp/environment.yml 7 | 8 | COPY . /tmp/stactools-sentinel2 9 | RUN cd /tmp/stactools-sentinel2 && pip install . && rm -rf /tmp/stactools-sentinel2 10 | 11 | ENTRYPOINT [ "python", "-m", "stactools.cli" ] 12 | -------------------------------------------------------------------------------- /docker/Dockerfile-dev: -------------------------------------------------------------------------------- 1 | FROM stactools-sentinel2:latest 2 | 3 | RUN conda install -c conda-forge pandoc 4 | 5 | COPY . /src/stactools-sentinel2 6 | RUN pip install -r /src/stactools-sentinel2/requirements-dev.txt 7 | ENV PYTHONPATH=/src/stactools-sentinel2/src:$PYTHONPATH 8 | WORKDIR /src/stactools-sentinel2 9 | -------------------------------------------------------------------------------- /docker/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${STACTOOLS_DEBUG}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | 11 | echo -n \ 12 | "Usage: $(basename "$0") 13 | Build stactools containers. Must be run from the repository root 14 | " 15 | } 16 | 17 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 18 | if [ "${1:-}" = "--help" ]; then 19 | usage 20 | else 21 | docker build -t stactools-sentinel2 -f docker/Dockerfile . 22 | docker build -t stactools-sentinel2-dev -f docker/Dockerfile-dev . 23 | fi 24 | exit 25 | fi 26 | -------------------------------------------------------------------------------- /docker/cibuild: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${STACTOOLS_DEBUG}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Runs CI in the docker dev container. 13 | " 14 | } 15 | 16 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 17 | ./docker/build 18 | docker run --rm \ 19 | --entrypoint /src/stactools-sentinel2/scripts/cibuild \ 20 | stactools-sentinel2-dev:latest 21 | fi 22 | -------------------------------------------------------------------------------- /docker/console: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${STACTOOLS_DEBUG}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Run a console in a docker container with all prerequisites installed. 13 | " 14 | } 15 | 16 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 17 | docker run --rm -it \ 18 | -v `pwd`:/src/stactools-sentinel2 \ 19 | --entrypoint /bin/bash \ 20 | stactools-sentinel2-dev:latest 21 | fi 22 | -------------------------------------------------------------------------------- /docker/docs-server: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${STACTOOLS_DEBUG}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Build and serve documentation from a docker container with all prerequisites installed. 13 | " 14 | } 15 | 16 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 17 | docker run --rm -it \ 18 | -p 8000:8000 \ 19 | -v `pwd`:/src/stactools-sentinel2 \ 20 | -w /src/stactools-sentinel2/docs \ 21 | --entrypoint /bin/bash \ 22 | stactools-sentinel2-dev:latest \ 23 | -c "make livehtml" 24 | fi 25 | -------------------------------------------------------------------------------- /docker/format: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${STACTOOLS_DEBUG}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Run code formatters in a docker container with all prerequisites installed. 13 | " 14 | } 15 | 16 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 17 | docker run --rm -it \ 18 | -v `pwd`:/src/stactools-sentinel2 \ 19 | --entrypoint /src/stactools-sentinel2/scripts/format \ 20 | stactools-sentinel2-dev:latest 21 | fi 22 | -------------------------------------------------------------------------------- /docker/notebook: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${STACTOOLS_DEBUG}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Launches a Jupyter notebook in a docker container with all prerequisites installed. 13 | " 14 | } 15 | 16 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 17 | docker run --rm -it \ 18 | -v `pwd`:/src/stactools-sentinel2 \ 19 | -v ${HOME}/.planet.json:/root/.planet.json:ro \ 20 | --entrypoint jupyter \ 21 | -p 8888:8888 \ 22 | stactools-sentinel2:latest \ 23 | notebook \ 24 | --ip=0.0.0.0 \ 25 | --port=8888 \ 26 | --no-browser \ 27 | --allow-root \ 28 | --notebook-dir=/opt/src 29 | fi 30 | -------------------------------------------------------------------------------- /docker/stac: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${STACTOOLS_DEBUG}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Run the stactools CLI in a docker container. Mounts the source 13 | directory inside the container, so any edits to the repository 14 | will be reflected in the execution. 15 | " 16 | } 17 | 18 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 19 | docker run --rm -it \ 20 | -v `pwd`:/src/stactools-sentinel2 \ 21 | stactools-sentinel2:latest "${@}" 22 | fi 23 | -------------------------------------------------------------------------------- /docker/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${STACTOOLS_DEBUG}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Run linting and tests in a docker container with all prerequisites installed. 13 | " 14 | } 15 | 16 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 17 | docker run --rm -it \ 18 | -v `pwd`:/src/stactools-sentinel2 \ 19 | --entrypoint /src/stactools-sentinel2/scripts/test \ 20 | stactools-sentinel2-dev:latest 21 | fi 22 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: stactools-sentinel2 2 | channels: 3 | - conda-forge 4 | - defaults 5 | dependencies: 6 | - conda-forge::gdal>=3.3 7 | - conda-forge::geos>=3.3 8 | - conda-forge::rasterio>=1.3 9 | - conda-forge::python 10 | -------------------------------------------------------------------------------- /examples/catalog.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Catalog", 3 | "id": "sentinel2-examples", 4 | "stac_version": "1.0.0", 5 | "description": "Example collections and items for stactools-sentinel2", 6 | "links": [ 7 | { 8 | "rel": "root", 9 | "href": "./catalog.json", 10 | "type": "application/json" 11 | }, 12 | { 13 | "rel": "child", 14 | "href": "./sentinel2-l1c-example/collection.json", 15 | "type": "application/json" 16 | }, 17 | { 18 | "rel": "child", 19 | "href": "./sentinel2-l2a-example/collection.json", 20 | "type": "application/json" 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /examples/sentinel2-l1c-example/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Collection", 3 | "id": "sentinel2-l1c-example", 4 | "stac_version": "1.0.0", 5 | "description": "Example collection of sentinel2 L1C data", 6 | "links": [ 7 | { 8 | "rel": "root", 9 | "href": "../catalog.json", 10 | "type": "application/json" 11 | }, 12 | { 13 | "rel": "item", 14 | "href": "./S2A_T01LAC_20200717T221944_L1C/S2A_T01LAC_20200717T221944_L1C.json", 15 | "type": "application/json" 16 | }, 17 | { 18 | "rel": "parent", 19 | "href": "../catalog.json", 20 | "type": "application/json" 21 | } 22 | ], 23 | "extent": { 24 | "spatial": { 25 | "bbox": [ 26 | [ 27 | 179.256949, 28 | -16.351724, 29 | -179.70343, 30 | -15.345454 31 | ] 32 | ] 33 | }, 34 | "temporal": { 35 | "interval": [ 36 | [ 37 | "2020-07-17T22:19:41.024000Z", 38 | "2020-07-17T22:19:41.024000Z" 39 | ] 40 | ] 41 | } 42 | }, 43 | "license": "proprietary" 44 | } -------------------------------------------------------------------------------- /examples/sentinel2-l2a-example/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Collection", 3 | "id": "sentinel2-l2a-example", 4 | "stac_version": "1.0.0", 5 | "description": "Example collection of sentinel2 L1C data", 6 | "links": [ 7 | { 8 | "rel": "root", 9 | "href": "../catalog.json", 10 | "type": "application/json" 11 | }, 12 | { 13 | "rel": "item", 14 | "href": "./S2A_T07HFE_20190212T192646_L2A/S2A_T07HFE_20190212T192646_L2A.json", 15 | "type": "application/json" 16 | }, 17 | { 18 | "rel": "parent", 19 | "href": "../catalog.json", 20 | "type": "application/json" 21 | } 22 | ], 23 | "extent": { 24 | "spatial": { 25 | "bbox": [ 26 | [ 27 | -139.94553, 28 | -31.771974, 29 | -139.57542, 30 | -31.625917 31 | ] 32 | ] 33 | }, 34 | "temporal": { 35 | "interval": [ 36 | [ 37 | "2019-02-12T19:26:51.024000Z", 38 | "2019-02-12T19:26:51.024000Z" 39 | ] 40 | ] 41 | } 42 | }, 43 | "license": "proprietary" 44 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "stactools-sentinel2" 3 | dynamic = ["version", "readme"] 4 | description = "Create STAC Items from Sentinel-2 metadata" 5 | authors = [{ "name" = "stac-utils", "email" = "stac@radiant.earth"}] 6 | keywords = [ "stactools", "pystac", "catalog", "STAC" ] 7 | classifiers = [ 8 | "Development Status :: 4 - Beta", 9 | "License :: OSI Approved :: Apache Software License", 10 | "Programming Language :: Python :: 3.9", 11 | "Programming Language :: Python :: 3.10", 12 | "Programming Language :: Python :: 3.11", 13 | "Programming Language :: Python :: 3.12", 14 | "Programming Language :: Python :: 3.13", 15 | ] 16 | requires-python = ">=3.9" 17 | 18 | dependencies = [ 19 | "antimeridian >= 0.3.5", 20 | "shapely >= 2.0.0", 21 | "stactools >= 0.5.2", 22 | "pyproj >= 3.5.0", 23 | "pystac >= 1.9.0", 24 | ] 25 | 26 | [project.optional-dependencies] 27 | dev = [ 28 | "codespell", 29 | "coverage", 30 | "pre-commit", 31 | "ruff", 32 | "pytest", 33 | "pytest-cov", 34 | "requests", 35 | "pystac<1.12", # until testing moves to STAC 1.1 36 | ] 37 | 38 | [project.urls] 39 | Url = "https://github.com/stactools-sentinel2/stactools-sentinel2" 40 | Issues = "https://github.com/stactools-sentinel2s/stactools-sentinel2/issues" 41 | Github = "https://github.com/stactools-sentinel2s/stactools-sentinel2" 42 | CHANGELOG = "https://github.com/stactools-sentinel2s/stactools-sentinel2/blob/main/CHANGELOG.md" 43 | 44 | [tool.setuptools.dynamic] 45 | version = {attr = "stactools.sentinel2.__version__" } 46 | readme = {file = ["README.md"], content-type = "text/plain"} 47 | 48 | [tool.setuptools] 49 | package-dir = { "" = "src" } 50 | 51 | [build-system] 52 | requires = ["setuptools", "wheel"] 53 | build-backend = "setuptools.build_meta" 54 | 55 | [tool.ruff] 56 | line-length = 88 57 | 58 | [tool.ruff.lint] 59 | select = ["E", "F", "I"] 60 | 61 | # pyproject.toml 62 | [tool.pytest.ini_options] 63 | filterwarnings = ["error", "ignore::antimeridian.FixWindingWarning"] 64 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | codespell 2 | coverage 3 | pre-commit 4 | ruff 5 | pytest 6 | pytest-cov 7 | requests 8 | -------------------------------------------------------------------------------- /scripts/cibuild: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${CI}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Execute project linters and test suites in CI. 13 | " 14 | } 15 | 16 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 17 | if [ "${1:-}" = "--help" ]; then 18 | usage 19 | else 20 | ./scripts/update 21 | ./scripts/test 22 | fi 23 | fi 24 | -------------------------------------------------------------------------------- /scripts/create_examples.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import datetime 4 | import shutil 5 | import warnings 6 | from pathlib import Path 7 | 8 | from antimeridian import FixWindingWarning 9 | from pystac import ( 10 | Catalog, 11 | CatalogType, 12 | Collection, 13 | Extent, 14 | SpatialExtent, 15 | TemporalExtent, 16 | ) 17 | 18 | from stactools.sentinel2.stac import create_item 19 | 20 | DEFAULT_EXTENT = Extent( 21 | SpatialExtent([[-180, -90, 180, 90]]), 22 | TemporalExtent([[datetime.datetime.utcnow(), None]]), 23 | ) 24 | 25 | root = Path(__file__).parents[1] 26 | examples = root / "examples" 27 | data_files = root / "tests" / "data-files" 28 | 29 | if examples.exists(): 30 | shutil.rmtree(examples) 31 | examples.mkdir() 32 | 33 | catalog = Catalog( 34 | id="sentinel2-examples", 35 | description="Example collections and items for stactools-sentinel2", 36 | ) 37 | 38 | l1c_collection = Collection( 39 | id="sentinel2-l1c-example", 40 | description="Example collection of sentinel2 L1C data", 41 | extent=DEFAULT_EXTENT, 42 | ) 43 | with warnings.catch_warnings(): 44 | warnings.filterwarnings("ignore", category=FixWindingWarning) 45 | l1c_item = create_item( 46 | str(data_files / "S2A_MSIL1C_20200717T221941_R029_T01LAC_20200717T234135.SAFE") 47 | ) 48 | l1c_collection.add_item(l1c_item) 49 | l1c_collection.update_extent_from_items() 50 | 51 | l2a_collection = Collection( 52 | id="sentinel2-l2a-example", 53 | description="Example collection of sentinel2 L1C data", 54 | extent=DEFAULT_EXTENT, 55 | ) 56 | with warnings.catch_warnings(): 57 | warnings.filterwarnings("ignore", category=FixWindingWarning) 58 | l2a_item = create_item( 59 | str( 60 | data_files 61 | / "S2A_MSIL2A_20190212T192651_N0212_R013_T07HFE_20201007T160857.SAFE" 62 | ) 63 | ) 64 | l2a_collection.add_item(l2a_item) 65 | l2a_collection.update_extent_from_items() 66 | 67 | catalog.add_children([l1c_collection, l2a_collection]) 68 | catalog.normalize_hrefs(str(examples)) 69 | catalog.make_all_asset_hrefs_relative() 70 | catalog.save(CatalogType.SELF_CONTAINED) 71 | -------------------------------------------------------------------------------- /scripts/create_expected.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pathlib import Path 4 | 5 | from stactools.sentinel2 import stac 6 | 7 | EXCLUDE = [ 8 | "S2A_T60CWS_20240109T203651_L2A-pole-and-antimeridian-bad-geometry-after-reprojection", 9 | "S2B_OPER_MSI_L2A_DS_VGS1_20201101T095401_S20201101T074429-no-data", 10 | "S2A_OPER_MSI_L2A_DS_2APS_20230105T201055_S20230105T163809", 11 | "S2A_MSIL2A_20150826T185436_N0212_R070_T11SLT_20210412T023147", 12 | "S2A_MSIL2A_20180721T053721_N0212_R062_T43MDV_20201011T181419.SAFE", 13 | "S2A_MSIL2A_20190212T192651_N0212_R013_T07HFE_20201007T160857", 14 | "S2A_MSIL1C_20210908T042701_N0301_R133_T46RER_20210908T070248", 15 | "S2B_MSIL2A_20191228T210519_N0212_R071_T01CCV_20201003T104658", 16 | "S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBQ-no-tileDataGeometry", 17 | ] 18 | 19 | root = Path(__file__).parents[1] 20 | data_files = root / "tests" / "data-files" 21 | 22 | for path in data_files.iterdir(): 23 | if path.name in EXCLUDE or path.name.startswith("."): 24 | continue 25 | print(path) 26 | item = stac.create_item(str(path)) 27 | item.set_self_href(str(data_files / path.name / "expected_output.json")) 28 | item.make_asset_hrefs_relative() 29 | item.validate() 30 | item.save_object(include_self_link=False) 31 | -------------------------------------------------------------------------------- /scripts/format: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${CI}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Format code 13 | " 14 | } 15 | 16 | DIRS_TO_CHECK=("src" "tests") 17 | 18 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 19 | if [ "${1:-}" = "--help" ]; then 20 | usage 21 | else 22 | # Code formatting 23 | pre-commit run ruff --all-files 24 | fi 25 | fi 26 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${CI}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Execute project linters. 13 | " 14 | } 15 | 16 | DIRS_TO_CHECK=("src" "tests") 17 | 18 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 19 | if [ "${1:-}" = "--help" ]; then 20 | usage 21 | else 22 | pre-commit run --all-files 23 | fi 24 | fi 25 | -------------------------------------------------------------------------------- /scripts/publish: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${CI}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Publish all stactools.sentinel2s. 13 | 14 | Options: 15 | --test Publish to test pypi 16 | " 17 | } 18 | 19 | POSITIONAL=() 20 | while [[ $# -gt 0 ]] 21 | do 22 | key="$1" 23 | case $key in 24 | 25 | --help) 26 | usage 27 | exit 0 28 | shift 29 | ;; 30 | 31 | --test) 32 | TEST_PYPI="--repository testpypi" 33 | shift 34 | ;; 35 | 36 | *) # unknown option 37 | POSITIONAL+=("$1") # save it in an array for later 38 | shift # past argument 39 | ;; 40 | esac 41 | done 42 | set -- "${POSITIONAL[@]}" # restore positional parameters 43 | 44 | # Fail if this isn't CI and we aren't publishing to test pypi 45 | if [ -z "${TEST_PYPI}" ] && [ -z "${CI}" ]; then 46 | echo "Only CI can publish to pypi" 47 | exit 1 48 | fi 49 | 50 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 51 | rm -rf dist 52 | python -m build # (default config is to build sdist and wheel) 53 | twine upload ${TEST_PYPI} dist/* 54 | fi 55 | -------------------------------------------------------------------------------- /scripts/stac: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${CI}" ]]; then 6 | set -x 7 | fi 8 | 9 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 10 | python -m stactools.cli "$@" 11 | fi 12 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${CI}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Execute project linters and test suites. 13 | " 14 | } 15 | 16 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 17 | if [ "${1:-}" = "--help" ]; then 18 | usage 19 | else 20 | ./scripts/lint 21 | ./scripts/format 22 | 23 | codespell -I .codespellignore -f \ 24 | scripts/* \ 25 | *.py ./**/*.py \ 26 | *.md \ 27 | docs/*.rst docs/**/*.rst \ 28 | docs/*.ipynb docs/**/*.ipynb 29 | 30 | pytest --cov=stactools tests/ 31 | coverage xml 32 | fi 33 | fi 34 | -------------------------------------------------------------------------------- /scripts/update: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${CI}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | Install requirements for all packages and development. 13 | " 14 | } 15 | 16 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 17 | if [ "${1:-}" = "--help" ]; then 18 | usage 19 | else 20 | python -m pip install --upgrade pip 21 | pip install ".[dev]" 22 | fi 23 | fi 24 | -------------------------------------------------------------------------------- /src/stactools/sentinel2/__init__.py: -------------------------------------------------------------------------------- 1 | import stactools.core 2 | 3 | stactools.core.use_fsspec() 4 | 5 | 6 | def register_plugin(registry): 7 | # Register subcommands 8 | 9 | from stactools.sentinel2 import commands 10 | 11 | registry.register_subcommand(commands.create_sentinel2_command) 12 | 13 | 14 | __version__ = "0.7.1" 15 | -------------------------------------------------------------------------------- /src/stactools/sentinel2/cog.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | from tempfile import TemporaryDirectory 5 | 6 | import pystac 7 | from pystac.utils import make_absolute_href 8 | 9 | from stactools.core.utils.convert import cogify 10 | from stactools.core.utils.subprocess import call 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | # TODO: Refactor this into general-purpose cogification of item asset 15 | # Currently does not allow the specification of specific assets to cogify. 16 | 17 | 18 | def create_cogs(item): 19 | cog_directory = os.path.join(os.path.dirname(item.get_self_href()), "cog") 20 | if not os.path.exists(cog_directory): 21 | os.makedirs(cog_directory) 22 | 23 | asset_tuples = list(item.assets.items()) 24 | [ 25 | item.add_asset(*create_cog_asset(key, asset, cog_directory)) 26 | for (key, asset) in asset_tuples 27 | if is_non_cog_image(asset) 28 | ] 29 | 30 | 31 | def create_cog_asset(key, asset, path): 32 | asset_filename, extension = os.path.splitext(os.path.split(asset.href)[1]) 33 | cog_filename = f"{asset_filename}-COG.tif" 34 | cog_path = os.path.join(path, cog_filename) 35 | 36 | with TemporaryDirectory() as tmp_dir: 37 | reprojected_path = os.path.join(tmp_dir, f"reprojected{extension}") 38 | print(f"reprojecting {asset.href}") 39 | reproject(asset.href, reprojected_path) 40 | print(f"cogifying {asset.href}") 41 | cogify(reprojected_path, cog_path) 42 | shutil.rmtree(tmp_dir, ignore_errors=True) 43 | 44 | asset = pystac.Asset( 45 | href=make_absolute_href(cog_path), 46 | media_type=pystac.MediaType.COG, 47 | roles=["data"], 48 | title=f"{asset.title} (COG)", 49 | properties=asset.properties, 50 | ) 51 | return f"{key}-cog", asset 52 | 53 | 54 | def is_non_cog_image(asset): 55 | return asset.media_type != pystac.MediaType.COG and ( 56 | asset.media_type == pystac.MediaType.GEOTIFF 57 | or asset.media_type == pystac.MediaType.JPEG2000 58 | ) 59 | 60 | 61 | def reproject(input_path, output_path): 62 | call(["gdalwarp", "-t_srs", "epsg:3857", input_path, output_path]) 63 | -------------------------------------------------------------------------------- /src/stactools/sentinel2/commands.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from typing import Optional 5 | 6 | import click 7 | 8 | from stactools.sentinel2.constants import DEFAULT_TOLERANCE 9 | from stactools.sentinel2.stac import create_item 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def create_sentinel2_command(cli): 15 | @cli.group("sentinel2", short_help="Commands for working with sentinel2 data") 16 | def sentinel2(): 17 | pass 18 | 19 | @sentinel2.command( 20 | "create-item", short_help="Convert a Sentinel2 L2A granule into a STAC item" 21 | ) 22 | @click.argument("src") 23 | @click.argument("dst") 24 | @click.option( 25 | "-p", 26 | "--providers", 27 | help="Path to JSON file containing array of additional providers", 28 | ) 29 | @click.option( 30 | "--tolerance", 31 | type=float, 32 | default=DEFAULT_TOLERANCE, 33 | help="Item geometry simplification tolerance, e.g., 0.0001", 34 | ) 35 | @click.option( 36 | "--asset-href-prefix", 37 | help='Prefix for all Asset hrefs instead of default of the "src" value', 38 | ) 39 | def create_item_command( 40 | src: str, 41 | dst: str, 42 | providers: Optional[str], 43 | tolerance: float, 44 | asset_href_prefix: Optional[str], 45 | ): 46 | """Creates a STAC Item for a given Sentinel 2 granule 47 | 48 | SRC is the path to the granule 49 | DST is directory that a STAC Item JSON file will be created 50 | in. This will have a filename that matches the ID, which will 51 | be derived from the Sentinel 2 metadata. 52 | """ 53 | additional_providers = None 54 | if providers is not None: 55 | with open(providers) as f: 56 | additional_providers = json.load(f) 57 | 58 | item = create_item( 59 | granule_href=src, 60 | additional_providers=additional_providers, 61 | tolerance=tolerance, 62 | asset_href_prefix=asset_href_prefix, 63 | ) 64 | 65 | item_path = os.path.join(dst, f"{item.id}.json") 66 | item.set_self_href(item_path) 67 | 68 | item.save_object() 69 | 70 | return sentinel2 71 | -------------------------------------------------------------------------------- /src/stactools/sentinel2/constants.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | import pystac 4 | from pystac.extensions.eo import Band 5 | from pystac.link import Link 6 | from pystac.provider import ProviderRole 7 | 8 | SENTINEL2_PROPERTY_PREFIX = "s2" 9 | 10 | SENTINEL2_EXTENSION_SCHEMA = ( 11 | "https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json" 12 | ) 13 | 14 | SENTINEL_LICENSE: Final[Link] = Link( 15 | rel="license", 16 | target="https://sentinel.esa.int/documents/247904/690755/Sentinel_Data_Legal_Notice", 17 | ) 18 | 19 | SENTINEL_INSTRUMENTS: Final[list[str]] = ["msi"] 20 | SENTINEL_CONSTELLATION: Final[str] = "sentinel-2" 21 | 22 | SENTINEL_PROVIDER: Final[pystac.Provider] = pystac.Provider( 23 | name="ESA", 24 | roles=[ProviderRole.PRODUCER, ProviderRole.PROCESSOR, ProviderRole.LICENSOR], 25 | url="https://earth.esa.int/web/guest/home", 26 | ) 27 | 28 | SAFE_MANIFEST_ASSET_KEY: Final[str] = "safe_manifest" 29 | INSPIRE_METADATA_ASSET_KEY: Final[str] = "inspire_metadata" 30 | PRODUCT_METADATA_ASSET_KEY: Final[str] = "product_metadata" 31 | GRANULE_METADATA_ASSET_KEY: Final[str] = "granule_metadata" 32 | DATASTRIP_METADATA_ASSET_KEY: Final[str] = "datastrip_metadata" 33 | TILEINFO_METADATA_ASSET_KEY: Final[str] = "tileinfo_metadata" 34 | 35 | DEFAULT_TOLERANCE: Final[float] = 0.01 36 | COORD_ROUNDING: Final[int] = 6 37 | 38 | SENTINEL_BANDS: Final[dict[str, Band]] = { 39 | "coastal": Band.create( 40 | name="B01", 41 | common_name="coastal", 42 | center_wavelength=0.443, 43 | full_width_half_max=0.027, 44 | ), 45 | "blue": Band.create( 46 | name="B02", 47 | common_name="blue", 48 | center_wavelength=0.490, 49 | full_width_half_max=0.098, 50 | ), 51 | "green": Band.create( 52 | name="B03", 53 | common_name="green", 54 | center_wavelength=0.560, 55 | full_width_half_max=0.045, 56 | ), 57 | "red": Band.create( 58 | name="B04", 59 | common_name="red", 60 | center_wavelength=0.665, 61 | full_width_half_max=0.038, 62 | ), 63 | "rededge1": Band.create( 64 | name="B05", 65 | common_name="rededge", 66 | center_wavelength=0.704, 67 | full_width_half_max=0.019, 68 | ), 69 | "rededge2": Band.create( 70 | name="B06", 71 | common_name="rededge", 72 | center_wavelength=0.740, 73 | full_width_half_max=0.018, 74 | ), 75 | "rededge3": Band.create( 76 | name="B07", 77 | common_name="rededge", 78 | center_wavelength=0.783, 79 | full_width_half_max=0.028, 80 | ), 81 | "nir": Band.create( 82 | name="B08", 83 | common_name="nir", 84 | center_wavelength=0.842, 85 | full_width_half_max=0.145, 86 | ), 87 | "nir08": Band.create( 88 | name="B8A", 89 | common_name="nir08", 90 | center_wavelength=0.865, 91 | full_width_half_max=0.033, 92 | ), 93 | "nir09": Band.create( 94 | name="B09", 95 | common_name="nir09", 96 | center_wavelength=0.945, 97 | full_width_half_max=0.026, 98 | ), 99 | "cirrus": Band.create( 100 | name="B10", 101 | common_name="cirrus", 102 | center_wavelength=1.3735, 103 | full_width_half_max=0.075, 104 | ), 105 | "swir16": Band.create( 106 | name="B11", 107 | common_name="swir16", 108 | center_wavelength=1.610, 109 | full_width_half_max=0.143, 110 | ), 111 | "swir22": Band.create( 112 | name="B12", 113 | common_name="swir22", 114 | center_wavelength=2.190, 115 | full_width_half_max=0.242, 116 | ), 117 | } 118 | 119 | # A dict describing the resolutions that are 120 | # available for each band as separate assets. 121 | # The first resolution is the sensor gsd; others 122 | # are downscaled versions. 123 | UNSUFFIXED_BAND_RESOLUTION: Final[dict[str, int]] = { 124 | "coastal": 60, 125 | "blue": 10, 126 | "green": 10, 127 | "red": 10, 128 | "rededge1": 20, 129 | "rededge2": 20, 130 | "rededge3": 20, 131 | "nir": 10, 132 | "nir08": 20, 133 | "nir09": 60, 134 | "cirrus": 60, 135 | "swir16": 20, 136 | "swir22": 20, 137 | "cloud": 20, 138 | "snow": 20, 139 | } 140 | 141 | BANDS_TO_ASSET_NAME: Final[dict[str, str]] = { 142 | "B01": "coastal", 143 | "B02": "blue", 144 | "B03": "green", 145 | "B04": "red", 146 | "B05": "rededge1", 147 | "B06": "rededge2", 148 | "B07": "rededge3", 149 | "B08": "nir", 150 | "B8A": "nir08", 151 | "B09": "nir09", 152 | "B10": "cirrus", 153 | "B11": "swir16", 154 | "B12": "swir22", 155 | } 156 | 157 | ASSET_TO_TITLE: Final[dict[str, str]] = { 158 | "coastal": "Coastal", 159 | "blue": "Blue", 160 | "green": "Green", 161 | "red": "Red", 162 | "rededge1": "Red Edge 1", 163 | "rededge2": "Red Edge 2", 164 | "rededge3": "Red Edge 3", 165 | "nir": "NIR 1", 166 | "nir08": "NIR 2", 167 | "nir09": "NIR 3", 168 | "cirrus": "Cirrus", 169 | "swir16": "SWIR 1.6μm", 170 | "swir22": "SWIR 2.2μm", 171 | } 172 | 173 | L2A_IMAGE_PATHS: Final[list[str]] = [ 174 | "R10m/B04.jp2", 175 | "R10m/B03.jp2", 176 | "R10m/B02.jp2", 177 | "R10m/WVP.jp2", 178 | "R10m/AOT.jp2", 179 | "R10m/TCI.jp2", 180 | "R10m/B08.jp2", 181 | "R20m/B12.jp2", 182 | "R20m/B06.jp2", 183 | "R20m/B07.jp2", 184 | "R20m/B05.jp2", 185 | "R20m/B11.jp2", 186 | "R20m/B04.jp2", 187 | "R20m/B03.jp2", 188 | "R20m/B02.jp2", 189 | "R20m/WVP.jp2", 190 | "R20m/B8A.jp2", 191 | "R20m/SCL.jp2", 192 | "R20m/AOT.jp2", 193 | "R20m/TCI.jp2", 194 | "R20m/B08.jp2", 195 | "R60m/B12.jp2", 196 | "R60m/B06.jp2", 197 | "R60m/B07.jp2", 198 | "R60m/B05.jp2", 199 | "R60m/B11.jp2", 200 | "R60m/B04.jp2", 201 | "R60m/B01.jp2", 202 | "R60m/B03.jp2", 203 | "R60m/B02.jp2", 204 | "R60m/WVP.jp2", 205 | "R60m/B8A.jp2", 206 | "R60m/SCL.jp2", 207 | "R60m/AOT.jp2", 208 | "R60m/B09.jp2", 209 | "R60m/TCI.jp2", 210 | "R60m/B08.jp2", 211 | "qi/CLD_20m.jp2", 212 | "qi/SNW_20m.jp2", 213 | "qi/L2A_PVI.jp2", 214 | ] 215 | 216 | L1C_IMAGE_PATHS: Final[list[str]] = [ 217 | "B01.jp2", 218 | "B02.jp2", 219 | "B03.jp2", 220 | "B04.jp2", 221 | "B05.jp2", 222 | "B06.jp2", 223 | "B07.jp2", 224 | "B08.jp2", 225 | "B8A.jp2", 226 | "B09.jp2", 227 | "B10.jp2", 228 | "B11.jp2", 229 | "B12.jp2", 230 | "TCI.jp2", 231 | ] 232 | -------------------------------------------------------------------------------- /src/stactools/sentinel2/granule_metadata.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from dataclasses import dataclass 5 | from re import Pattern 6 | from typing import Final 7 | 8 | import pystac 9 | from pystac.utils import map_opt 10 | 11 | from stactools.core.io import ReadHrefModifier 12 | from stactools.core.io.xml import XmlElement 13 | from stactools.sentinel2.constants import GRANULE_METADATA_ASSET_KEY 14 | from stactools.sentinel2.constants import SENTINEL2_PROPERTY_PREFIX as s2_prefix 15 | 16 | BASELINE_PROCESSING: Final[Pattern[str]] = re.compile(r"_N(\d\d\.\d\d)") 17 | 18 | 19 | class GranuleMetadataError(Exception): 20 | pass 21 | 22 | 23 | class GranuleMetadata: 24 | def __init__(self, href, read_href_modifier: ReadHrefModifier | None = None): 25 | self.href = href 26 | 27 | self._root = XmlElement.from_file(href, read_href_modifier) 28 | 29 | tile_id = self._root.find_text("n1:General_Info/TILE_ID") 30 | if tile_id is None: 31 | raise GranuleMetadataError( 32 | f"Cannot find granule tile_id granule metadata at {self.href}" 33 | ) 34 | self.tile_id = tile_id 35 | 36 | geocoding_node = self._root.find("n1:Geometric_Info/Tile_Geocoding") 37 | if geocoding_node is None: 38 | raise GranuleMetadataError(f"Cannot find geocoding node in {self.href}") 39 | self._geocoding_node = geocoding_node 40 | 41 | tile_angles_node = self._root.find("n1:Geometric_Info/Tile_Angles") 42 | if tile_angles_node is None: 43 | raise GranuleMetadataError(f"Cannot find tile angles node in {self.href}") 44 | self._tile_angles_node = tile_angles_node 45 | 46 | self.viewing_angles = ViewingAngle.from_nodes( 47 | self._tile_angles_node.findall( 48 | "Mean_Viewing_Incidence_Angle_List/Mean_Viewing_Incidence_Angle" 49 | ) 50 | ) 51 | 52 | self._image_content_node = self._root.find( 53 | "n1:Quality_Indicators_Info/Image_Content_QI" 54 | ) 55 | 56 | self.resolution_to_shape: dict[int, tuple[int, int]] = {} 57 | for size_node in self._geocoding_node.findall("Size"): 58 | res = size_node.get_attr("resolution") 59 | if res is None: 60 | raise GranuleMetadataError("Size element does not have resolution.") 61 | nrows = map_opt(int, size_node.find_text("NROWS")) 62 | if nrows is None: 63 | raise GranuleMetadataError( 64 | f"Could not get rows from size for resolution {res}" 65 | ) 66 | ncols = map_opt(int, size_node.find_text("NCOLS")) 67 | if ncols is None: 68 | raise GranuleMetadataError( 69 | f"Could not get columns from size for resolution {res}" 70 | ) 71 | self.resolution_to_shape[int(res)] = (nrows, ncols) 72 | 73 | # calculate the 320 m array shape, as it is not included in the metadata, 74 | # by approximation from the 10 m array shape. 75 | self.resolution_to_shape[320] = ( 76 | int(self.resolution_to_shape[10][0] * 10 / 320), 77 | int(self.resolution_to_shape[10][1] * 10 / 320), 78 | ) 79 | 80 | @property 81 | def epsg(self) -> int | None: 82 | epsg_str = self._geocoding_node.find_text("HORIZONTAL_CS_CODE") 83 | if epsg_str is None: 84 | return None 85 | else: 86 | return int(epsg_str.split(":")[1]) 87 | 88 | @property 89 | def proj_bbox(self) -> list[float]: 90 | """The bbox of the image in the CRS of the image data""" 91 | nrows, ncols = self.resolution_to_shape[10] 92 | geoposition = self._geocoding_node.find("Geoposition") 93 | if geoposition is None: 94 | raise GranuleMetadataError(f"Cannot find geoposition node in {self.href}") 95 | ulx = map_opt(float, geoposition.find_text("ULX")) 96 | if ulx is None: 97 | raise GranuleMetadataError("Could not get upper left X coordinate") 98 | uly = map_opt(float, geoposition.find_text("ULY")) 99 | if uly is None: 100 | raise GranuleMetadataError("Could not get upper left Y coordinate") 101 | 102 | return [ulx, uly - (10 * nrows), ulx + (10 * ncols), uly] 103 | 104 | @property 105 | def cloudiness_percentage(self) -> float | None: 106 | return map_opt( 107 | float, 108 | self._image_content_node.find_text("CLOUDY_PIXEL_PERCENTAGE"), 109 | ) 110 | 111 | @property 112 | def snow_ice_percentage(self) -> float | None: 113 | return map_opt( 114 | float, 115 | self._image_content_node.find_text("SNOW_ICE_PERCENTAGE"), 116 | ) 117 | 118 | @property 119 | def mean_solar_zenith(self) -> float | None: 120 | return map_opt( 121 | float, 122 | self._tile_angles_node.find_text("Mean_Sun_Angle/ZENITH_ANGLE"), 123 | ) 124 | 125 | @property 126 | def mean_solar_azimuth(self) -> float | None: 127 | return map_opt( 128 | float, 129 | self._tile_angles_node.find_text("Mean_Sun_Angle/AZIMUTH_ANGLE"), 130 | ) 131 | 132 | @property 133 | def metadata_dict(self): 134 | properties: dict[str, float | None] = { 135 | f"{s2_prefix}:tile_id": self.tile_id, 136 | f"{s2_prefix}:product_type": map_opt( 137 | float, self._image_content_node.find_text("PRODUCT_TYPE") 138 | ), 139 | f"{s2_prefix}:orbit_state": map_opt( 140 | float, self._image_content_node.find_text("SENSING_ORBIT_DIRECTION") 141 | ), 142 | f"{s2_prefix}:datatake_type": map_opt( 143 | float, self._image_content_node.find_text("DATATAKE_TYPE") 144 | ), 145 | f"{s2_prefix}:generation_time": map_opt( 146 | float, self._image_content_node.find_text("GENERATION_TIME") 147 | ), 148 | f"{s2_prefix}:relative_orbit": map_opt( 149 | float, self._image_content_node.find_text("SENSING_ORBIT_NUMBER") 150 | ), 151 | f"{s2_prefix}:reflectance_conversion_factor": map_opt( 152 | float, 153 | self._image_content_node.find_text( 154 | "BOA_ADD_OFFSET_VALUES_LIST/Reflectance_Conversion/U" 155 | ), 156 | ), 157 | f"{s2_prefix}:degraded_msi_data_percentage": map_opt( 158 | float, 159 | self._image_content_node.find_text("DEGRADED_MSI_DATA_PERCENTAGE"), 160 | ), 161 | f"{s2_prefix}:nodata_pixel_percentage": map_opt( 162 | float, self._image_content_node.find_text("NODATA_PIXEL_PERCENTAGE") 163 | ), 164 | f"{s2_prefix}:saturated_defective_pixel_percentage": map_opt( 165 | float, 166 | self._image_content_node.find_text( 167 | "SATURATED_DEFECTIVE_PIXEL_PERCENTAGE" 168 | ), 169 | ), 170 | f"{s2_prefix}:dark_features_percentage": map_opt( 171 | float, self._image_content_node.find_text("DARK_FEATURES_PERCENTAGE") 172 | ), 173 | f"{s2_prefix}:cloud_shadow_percentage": map_opt( 174 | float, self._image_content_node.find_text("CLOUD_SHADOW_PERCENTAGE") 175 | ), 176 | f"{s2_prefix}:vegetation_percentage": map_opt( 177 | float, self._image_content_node.find_text("VEGETATION_PERCENTAGE") 178 | ), 179 | f"{s2_prefix}:not_vegetated_percentage": map_opt( 180 | float, self._image_content_node.find_text("NOT_VEGETATED_PERCENTAGE") 181 | ), 182 | f"{s2_prefix}:water_percentage": map_opt( 183 | float, self._image_content_node.find_text("WATER_PERCENTAGE") 184 | ), 185 | f"{s2_prefix}:unclassified_percentage": map_opt( 186 | float, self._image_content_node.find_text("UNCLASSIFIED_PERCENTAGE") 187 | ), 188 | f"{s2_prefix}:medium_proba_clouds_percentage": map_opt( 189 | float, 190 | self._image_content_node.find_text("MEDIUM_PROBA_CLOUDS_PERCENTAGE"), 191 | ), 192 | f"{s2_prefix}:high_proba_clouds_percentage": map_opt( 193 | float, 194 | self._image_content_node.find_text("HIGH_PROBA_CLOUDS_PERCENTAGE"), 195 | ), 196 | f"{s2_prefix}:thin_cirrus_percentage": map_opt( 197 | float, self._image_content_node.find_text("THIN_CIRRUS_PERCENTAGE") 198 | ), 199 | f"{s2_prefix}:snow_ice_percentage": map_opt( 200 | float, self._image_content_node.find_text("SNOW_ICE_PERCENTAGE") 201 | ), 202 | } 203 | 204 | return {k: v for k, v in properties.items() if v is not None} 205 | 206 | @property 207 | def product_id(self) -> str: 208 | return self.tile_id 209 | 210 | @property 211 | def scene_id(self) -> str: 212 | """Returns the string to be used for a STAC Item id. 213 | Removes the processing number and .SAFE extension 214 | from the product_id defined below. 215 | Parsed based on the naming convention found here: 216 | https://sentinel.esa.int/web/sentinel/user-guides/sentinel-2-msi/naming-convention 217 | """ 218 | # Ensure the product id (PRODUCT_URI) is as expected. 219 | id_parts = self.product_id.split("_") 220 | 221 | # Remove PDGS Processing Baseline number 222 | id_parts = [part for part in id_parts if not part.startswith("N")] 223 | 224 | return "_".join(id_parts) 225 | 226 | @property 227 | def platform(self) -> str | None: 228 | if self.tile_id.startswith("S2A"): 229 | return "sentinel-2a" 230 | elif self.tile_id.startswith("S2B"): 231 | return "sentinel-2b" 232 | elif self.tile_id.startswith("S2C"): 233 | return "sentinel-2c" 234 | elif self.tile_id.startswith("S2D"): 235 | return "sentinel-2d" 236 | else: 237 | return None 238 | 239 | @property 240 | def processing_baseline(self) -> str | None: 241 | """Returns the string to be used for the baseline_processing property 242 | Parsed based on the naming convention found here: 243 | https://sentinel.esa.int/web/sentinel/user-guides/sentinel-2-msi/naming-convention 244 | """ 245 | mgrs_match = BASELINE_PROCESSING.search(self.product_id) 246 | if mgrs_match: 247 | return mgrs_match.group(1) 248 | return None 249 | 250 | @property 251 | def pvi_filename(self) -> str | None: 252 | return self._root.find_text("n1:Quality_Indicators_Info/PVI_FILENAME") 253 | 254 | def create_asset(self): 255 | asset = pystac.Asset( 256 | href=self.href, media_type=pystac.MediaType.XML, roles=["metadata"] 257 | ) 258 | return GRANULE_METADATA_ASSET_KEY, asset 259 | 260 | 261 | @dataclass 262 | class ViewingAngle: 263 | azimuth: float 264 | zenith: float 265 | 266 | @classmethod 267 | def from_nodes(cls, nodes: list[XmlElement]) -> dict[str, ViewingAngle]: 268 | angles = dict() 269 | for node in nodes: 270 | band_id_str = node.get_attr("bandId") 271 | if band_id_str is None: 272 | raise ValueError("expected band id on viewing angle node") 273 | else: 274 | band_id = int(band_id_str) 275 | if band_id < 8: 276 | band = f"B0{band_id + 1}" 277 | elif band_id == 8: 278 | band = "B8A" 279 | else: 280 | band = f"B{band_id:02}" 281 | zenith = float( 282 | node.find_text_or_throw( 283 | "ZENITH_ANGLE", lambda s: ValueError(f"missing ZENITH_ANGLE: {s}") 284 | ) 285 | ) 286 | azimuth = float( 287 | node.find_text_or_throw( 288 | "AZIMUTH_ANGLE", lambda s: ValueError(f"missing AZIMUTH_ANGLE: {s}") 289 | ) 290 | ) 291 | angles[band] = cls(azimuth=azimuth, zenith=zenith) 292 | return angles 293 | -------------------------------------------------------------------------------- /src/stactools/sentinel2/mgrs.py: -------------------------------------------------------------------------------- 1 | """Implements the :stac-ext:`MGRS Extension `.""" 2 | 3 | import re 4 | from re import Pattern 5 | from typing import Any, Optional, Union, cast 6 | 7 | import pystac 8 | from pystac.extensions.base import ExtensionManagementMixin, PropertiesExtension 9 | from pystac.extensions.hooks import ExtensionHooks 10 | 11 | SCHEMA_URI: str = "https://stac-extensions.github.io/mgrs/v1.0.0/schema.json" 12 | PREFIX: str = "mgrs:" 13 | 14 | # Field names 15 | LATITUDE_BAND_PROP: str = PREFIX + "latitude_band" # required 16 | GRID_SQUARE_PROP: str = PREFIX + "grid_square" # required 17 | UTM_ZONE_PROP: str = PREFIX + "utm_zone" 18 | 19 | LATITUDE_BANDS: frozenset[str] = frozenset( 20 | { 21 | "C", 22 | "D", 23 | "E", 24 | "F", 25 | "G", 26 | "H", 27 | "J", 28 | "K", 29 | "L", 30 | "M", 31 | "N", 32 | "P", 33 | "Q", 34 | "R", 35 | "S", 36 | "T", 37 | "U", 38 | "V", 39 | "W", 40 | "X", 41 | } 42 | ) 43 | 44 | UTM_ZONES: frozenset[int] = frozenset( 45 | { 46 | 1, 47 | 2, 48 | 3, 49 | 4, 50 | 5, 51 | 6, 52 | 7, 53 | 8, 54 | 9, 55 | 10, 56 | 11, 57 | 12, 58 | 13, 59 | 14, 60 | 15, 61 | 16, 62 | 17, 63 | 18, 64 | 19, 65 | 20, 66 | 21, 67 | 22, 68 | 23, 69 | 24, 70 | 25, 71 | 26, 72 | 27, 73 | 28, 74 | 29, 75 | 30, 76 | 31, 77 | 32, 78 | 33, 79 | 34, 80 | 35, 81 | 36, 82 | 37, 83 | 38, 84 | 39, 85 | 40, 86 | 41, 87 | 42, 88 | 43, 89 | 44, 90 | 45, 91 | 46, 92 | 47, 93 | 48, 94 | 49, 95 | 50, 96 | 51, 97 | 52, 98 | 53, 99 | 54, 100 | 55, 101 | 56, 102 | 57, 103 | 58, 104 | 59, 105 | 60, 106 | } 107 | ) 108 | 109 | GRID_SQUARE_REGEX: str = ( 110 | r"[ABCDEFGHJKLMNPQRSTUVWXYZ][ABCDEFGHJKLMNPQRSTUV](\d{2}|\d{4}|\d{6}|\d{8}|\d{10})?" 111 | ) 112 | GRID_SQUARE_PATTERN: Pattern[str] = re.compile(GRID_SQUARE_REGEX) 113 | 114 | 115 | def validated_latitude_band(v: str) -> str: 116 | if not isinstance(v, str): 117 | raise ValueError("Invalid MGRS latitude band: must be str") 118 | if v not in LATITUDE_BANDS: 119 | raise ValueError(f"Invalid MGRS latitude band: {v} is not in {LATITUDE_BANDS}") 120 | return v 121 | 122 | 123 | def validated_grid_square(v: str) -> str: 124 | if not isinstance(v, str): 125 | raise ValueError("Invalid MGRS grid square identifier: must be str") 126 | if not GRID_SQUARE_PATTERN.fullmatch(v): 127 | raise ValueError( 128 | f"Invalid MGRS grid square identifier: {v}" 129 | f" does not match the regex {GRID_SQUARE_REGEX}" 130 | ) 131 | return v 132 | 133 | 134 | def validated_utm_zone(v: Optional[int]) -> Optional[int]: 135 | if v is not None and not isinstance(v, int): 136 | raise ValueError("Invalid MGRS utm zone: must be None or str") 137 | if v is not None and v not in UTM_ZONES: 138 | raise ValueError(f"Invalid MGRS UTM zone: {v} is not in {UTM_ZONES}") 139 | return v 140 | 141 | 142 | class MgrsExtension( 143 | PropertiesExtension, 144 | ExtensionManagementMixin[Union[pystac.Item, pystac.Collection]], 145 | ): 146 | """A concrete implementation of :class:`MgrsExtension` on an :class:`~pystac.Item` 147 | that extends the properties of the Item to include properties defined in the 148 | :stac-ext:`MGRS Extension `. 149 | 150 | This class should generally not be instantiated directly. Instead, call 151 | :meth:`MgrsExtension.ext` on an :class:`~pystac.Item` to extend it. 152 | 153 | .. code-block:: python 154 | 155 | >>> item: pystac.Item = ... 156 | >>> proj_ext = MgrsExtension.ext(item) 157 | """ 158 | 159 | item: pystac.Item 160 | """The :class:`~pystac.Item` being extended.""" 161 | 162 | properties: dict[str, Any] 163 | """The :class:`~pystac.Item` properties, including extension properties.""" 164 | 165 | def __init__(self, item: pystac.Item): 166 | self.item = item 167 | self.properties = item.properties 168 | 169 | def __repr__(self) -> str: 170 | return f"" 171 | 172 | def apply( 173 | self, 174 | latitude_band: str, 175 | grid_square: str, 176 | utm_zone: Optional[int] = None, 177 | ) -> None: 178 | """Applies MGRS extension properties to the extended Item. 179 | 180 | Args: 181 | latitude_band : REQUIRED. The latitude band of the Item's centroid. 182 | grid_square : REQUIRED. MGRS grid square of the Item's centroid. 183 | utm_zone : The UTM Zone of the Item centroid. 184 | """ 185 | self.latitude_band = validated_latitude_band(latitude_band) 186 | self.grid_square = validated_grid_square(grid_square) 187 | self.utm_zone = validated_utm_zone(utm_zone) 188 | 189 | @property 190 | def latitude_band(self) -> Optional[str]: 191 | """Get or sets the latitude band of the datasource.""" 192 | return self._get_property(LATITUDE_BAND_PROP, str) 193 | 194 | @latitude_band.setter 195 | def latitude_band(self, v: str) -> None: 196 | self._set_property( 197 | LATITUDE_BAND_PROP, validated_latitude_band(v), pop_if_none=False 198 | ) 199 | 200 | @property 201 | def grid_square(self) -> Optional[str]: 202 | """Get or sets the latitude band of the datasource.""" 203 | return self._get_property(GRID_SQUARE_PROP, str) 204 | 205 | @grid_square.setter 206 | def grid_square(self, v: str) -> None: 207 | self._set_property( 208 | GRID_SQUARE_PROP, validated_grid_square(v), pop_if_none=False 209 | ) 210 | 211 | @property 212 | def utm_zone(self) -> Optional[int]: 213 | """Get or sets the latitude band of the datasource.""" 214 | return self._get_property(UTM_ZONE_PROP, int) 215 | 216 | @utm_zone.setter 217 | def utm_zone(self, v: Optional[int]) -> None: 218 | self._set_property(UTM_ZONE_PROP, validated_utm_zone(v), pop_if_none=False) 219 | 220 | @classmethod 221 | def get_schema_uri(cls) -> str: 222 | return SCHEMA_URI 223 | 224 | @classmethod 225 | def ext(cls, obj: pystac.Item, add_if_missing: bool = False) -> "MgrsExtension": 226 | """Extends the given STAC Object with properties from the :stac-ext:`MGRS 227 | Extension `. 228 | 229 | This extension can be applied to instances of :class:`~pystac.Item`. 230 | 231 | Raises: 232 | 233 | pystac.ExtensionTypeError : If an invalid object type is passed. 234 | """ 235 | if isinstance(obj, pystac.Item): 236 | cls.ensure_has_extension(obj, add_if_missing) 237 | return cast(MgrsExtension, MgrsExtension(obj)) 238 | else: 239 | raise pystac.ExtensionTypeError( 240 | f"MGRS Extension does not apply to type '{type(obj).__name__}'" 241 | ) 242 | 243 | 244 | class MgrsExtensionHooks(ExtensionHooks): 245 | schema_uri: str = SCHEMA_URI 246 | prev_extension_ids: set[str] = set() 247 | stac_object_types = {pystac.STACObjectType.ITEM} 248 | 249 | 250 | MGRS_EXTENSION_HOOKS: ExtensionHooks = MgrsExtensionHooks() 251 | -------------------------------------------------------------------------------- /src/stactools/sentinel2/product_metadata.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Optional 3 | 4 | import pystac 5 | from pystac.utils import map_opt, str_to_datetime 6 | from shapely.geometry import Polygon, mapping 7 | 8 | from stactools.core.io import ReadHrefModifier 9 | from stactools.core.io.xml import XmlElement 10 | from stactools.sentinel2.constants import COORD_ROUNDING, PRODUCT_METADATA_ASSET_KEY 11 | from stactools.sentinel2.constants import SENTINEL2_PROPERTY_PREFIX as s2_prefix 12 | from stactools.sentinel2.utils import fix_z_values 13 | 14 | 15 | class ProductMetadataError(Exception): 16 | pass 17 | 18 | 19 | class ProductMetadata: 20 | def __init__( 21 | self, href, read_href_modifier: Optional[ReadHrefModifier] = None 22 | ) -> None: 23 | self.href = href 24 | self._root = XmlElement.from_file(href, read_href_modifier) 25 | 26 | product_info_node = self._root.find("n1:General_Info/Product_Info") 27 | if product_info_node is None: 28 | raise ProductMetadataError( 29 | f"Cannot find product info node for product metadata at {self.href}" 30 | ) 31 | self.product_info_node = product_info_node 32 | 33 | datatake_node = self.product_info_node.find("Datatake") 34 | if datatake_node is None: 35 | raise ProductMetadataError( 36 | f"Cannot find Datatake node in product metadata at {self.href}" 37 | ) 38 | self.datatake_node = datatake_node 39 | 40 | granule_node = self.product_info_node.find( 41 | "Product_Organisation/Granule_List/Granule" 42 | ) 43 | if granule_node is None: 44 | raise ProductMetadataError( 45 | f"Cannot find granule node in product metadata at {self.href}" 46 | ) 47 | self.granule_node = granule_node 48 | 49 | reflectance_conversion_node = self._root.find( 50 | "n1:General_Info/Product_Image_Characteristics/Reflectance_Conversion" 51 | ) 52 | if reflectance_conversion_node is None: 53 | raise ProductMetadataError( 54 | "Could not find reflectance conversion node in product metadata at " 55 | f"{self.href}" 56 | ) 57 | self.reflectance_conversion_node = reflectance_conversion_node 58 | 59 | qa_node = self._root.find("n1:Quality_Indicators_Info") 60 | if qa_node is None: 61 | raise ProductMetadataError( 62 | f"Could not find QA node in product metadata at {self.href}" 63 | ) 64 | self.qa_node = qa_node 65 | 66 | # BOA_ADD_OFFSET_VALUES_LIST only exists in processing baseline 04.00 and higher 67 | boa_add_offset_values_list_node = self._root.find( 68 | "n1:General_Info/Product_Image_Characteristics/BOA_ADD_OFFSET_VALUES_LIST" 69 | ) 70 | self.boa_add_offset_values_list_node = boa_add_offset_values_list_node 71 | 72 | def _get_geometries(): 73 | geometric_info = self._root.find("n1:Geometric_Info") 74 | footprint_text = geometric_info.find_text( 75 | "Product_Footprint/Product_Footprint/Global_Footprint/EXT_POS_LIST" 76 | ) 77 | if footprint_text is None: 78 | ProductMetadataError( 79 | f"Cannot parse footprint from product metadata at {self.href}" 80 | ) 81 | 82 | footprint_coords = fix_z_values(footprint_text.split(" ")) 83 | footprint_points = [ 84 | p[::-1] 85 | for p in list( 86 | zip( 87 | *[ 88 | iter( 89 | round(coord, COORD_ROUNDING) 90 | for coord in footprint_coords 91 | ) 92 | ] 93 | * 2 94 | ) 95 | ) 96 | ] 97 | footprint_polygon = Polygon(footprint_points) 98 | geometry = mapping(footprint_polygon) 99 | bbox = footprint_polygon.bounds 100 | return bbox, geometry 101 | 102 | self.bbox, self.geometry = _get_geometries() 103 | 104 | @property 105 | def scene_id(self) -> str: 106 | """Returns the string to be used for a STAC Item id. 107 | 108 | Removes the processing number and .SAFE extension 109 | from the product_id defined below. 110 | 111 | Parsed based on the naming convention found here: 112 | https://sentinel.esa.int/web/sentinel/user-guides/sentinel-2-msi/naming-convention 113 | """ 114 | product_id = self.product_id 115 | # Ensure the product id (PRODUCT_URI) is as expected. 116 | if not product_id.endswith(".SAFE"): 117 | raise ValueError( 118 | "Unexpected value found at " 119 | f"General_Info/Product_Info: {product_id}. " 120 | "This was expected to follow the sentinel 2 " 121 | "naming convention, including " 122 | "ending in .SAFE" 123 | ) 124 | # product_id: S2A_MSIL2A_20230419T153551_N0509_R111_T19TDJ_20230419T220859.SAFE 125 | id_parts = self.product_id.split("_") 126 | sensor_id = id_parts[0] 127 | tile_id = id_parts[5].lstrip("T") 128 | 129 | # get datastrip sensing time to use as datetime 130 | # datastrip_id: S2A_OPER_MSI_L2A_DS_2APS_20230419T220859_S20230419T153818_N05.09 131 | datastrip_id = self.metadata_dict["s2:datastrip_id"].split("_") 132 | dt = datastrip_id[-2].lstrip("S") 133 | processing_level = datastrip_id[3] 134 | 135 | return f"{sensor_id}_T{tile_id}_{dt}_{processing_level}" 136 | 137 | @property 138 | def product_id(self) -> str: 139 | result = self.product_info_node.find_text("PRODUCT_URI") 140 | if result is None: 141 | raise ValueError( 142 | f"Cannot determine product ID using product metadata at {self.href}" 143 | ) 144 | else: 145 | return result 146 | 147 | @property 148 | def datetime(self) -> datetime: 149 | time = self.product_info_node.find_text("PRODUCT_START_TIME") 150 | if time is None: 151 | raise ValueError( 152 | "Cannot determine product start time using product metadata " 153 | f"at {self.href}" 154 | ) 155 | else: 156 | return str_to_datetime(time) 157 | 158 | @property 159 | def image_media_type(self) -> str: 160 | if self.granule_node.get_attr("imageFormat") == "GeoTIFF": 161 | return pystac.MediaType.COG 162 | else: 163 | return pystac.MediaType.JPEG2000 164 | 165 | @property 166 | def image_paths(self) -> list[str]: 167 | extension = ".tif" if self.image_media_type == pystac.MediaType.COG else ".jp2" 168 | 169 | return [f"{x.text}{extension}" for x in self.granule_node.findall("IMAGE_FILE")] 170 | 171 | @property 172 | def relative_orbit(self) -> Optional[int]: 173 | return map_opt(int, self.datatake_node.find_text("SENSING_ORBIT_NUMBER")) 174 | 175 | @property 176 | def orbit_state(self) -> Optional[str]: 177 | return self.datatake_node.find_text("SENSING_ORBIT_DIRECTION") 178 | 179 | @property 180 | def platform(self) -> Optional[str]: 181 | return self.datatake_node.find_text("SPACECRAFT_NAME") 182 | 183 | @property 184 | def metadata_dict(self) -> dict[str, Any]: 185 | result = { 186 | f"{s2_prefix}:product_uri": self.product_id, 187 | f"{s2_prefix}:generation_time": self.product_info_node.find_text( 188 | "GENERATION_TIME" 189 | ), 190 | f"{s2_prefix}:processing_baseline": self.product_info_node.find_text( 191 | "PROCESSING_BASELINE" 192 | ), 193 | f"{s2_prefix}:product_type": self.product_info_node.find_text( 194 | "PRODUCT_TYPE" 195 | ), 196 | f"{s2_prefix}:datatake_id": self.datatake_node.get_attr( 197 | "datatakeIdentifier" 198 | ), 199 | f"{s2_prefix}:datatake_type": self.datatake_node.find_text("DATATAKE_TYPE"), 200 | f"{s2_prefix}:datastrip_id": self.granule_node.get_attr( 201 | "datastripIdentifier" 202 | ), 203 | f"{s2_prefix}:tile_id": self.granule_node.get_attr("granuleIdentifier"), 204 | f"{s2_prefix}:reflectance_conversion_factor": map_opt( 205 | float, self.reflectance_conversion_node.find_text("U") 206 | ), 207 | } 208 | 209 | return {k: v for k, v in result.items() if v is not None} 210 | 211 | @property 212 | def boa_add_offsets(self) -> dict[str, int]: 213 | if self.boa_add_offset_values_list_node is not None: 214 | xs = { 215 | x.get_attr("band_id"): int(x.text) 216 | for x in self.boa_add_offset_values_list_node.findall("BOA_ADD_OFFSET") 217 | } 218 | return { 219 | "B01": xs["0"], 220 | "B02": xs["1"], 221 | "B03": xs["2"], 222 | "B04": xs["3"], 223 | "B05": xs["4"], 224 | "B06": xs["5"], 225 | "B07": xs["6"], 226 | "B08": xs["7"], 227 | "B8A": xs["8"], 228 | "B09": xs["9"], 229 | "B10": xs["10"], 230 | "B11": xs["11"], 231 | "B12": xs["12"], 232 | } 233 | else: 234 | return {} 235 | 236 | def create_asset(self): 237 | asset = pystac.Asset( 238 | href=self.href, media_type=pystac.MediaType.XML, roles=["metadata"] 239 | ) 240 | return PRODUCT_METADATA_ASSET_KEY, asset 241 | -------------------------------------------------------------------------------- /src/stactools/sentinel2/safe_manifest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | import pystac 5 | 6 | from stactools.core.io import ReadHrefModifier 7 | from stactools.core.io.xml import XmlElement 8 | from stactools.sentinel2.constants import SAFE_MANIFEST_ASSET_KEY 9 | 10 | 11 | class ManifestError(Exception): 12 | pass 13 | 14 | 15 | class SafeManifest: 16 | def __init__( 17 | self, granule_href: str, read_href_modifier: Optional[ReadHrefModifier] = None 18 | ): 19 | self.granule_href = granule_href 20 | self.href = os.path.join(granule_href, "manifest.safe") 21 | 22 | root = XmlElement.from_file(self.href, read_href_modifier) 23 | self._data_object_section = root.find("dataObjectSection") 24 | if self._data_object_section is None: 25 | raise ManifestError( 26 | f"Manifest at {self.href} does not have a dataObjectSection" 27 | ) 28 | 29 | def _find_href(self, xpaths: list[str]) -> Optional[str]: 30 | file_path = None 31 | for xpath in xpaths: 32 | file_path = self._data_object_section.find_attr("href", xpath) 33 | if file_path is not None: 34 | break 35 | 36 | if file_path is None: 37 | return None 38 | else: 39 | # Remove relative prefix that some paths have 40 | file_path = file_path.strip("./") 41 | return os.path.join(self.granule_href, file_path) 42 | 43 | @property 44 | def product_metadata_href(self) -> Optional[str]: 45 | return self._find_href( 46 | [ 47 | 'dataObject[@ID="S2_Level-1C_Product_Metadata"]/byteStream/fileLocation', 48 | 'dataObject[@ID="S2_Level-2A_Product_Metadata"]/byteStream/fileLocation', 49 | ] 50 | ) 51 | 52 | @property 53 | def inspire_metadata_href(self) -> Optional[str]: 54 | return self._find_href( 55 | ['dataObject[@ID="INSPIRE_Metadata"]/byteStream/fileLocation'] 56 | ) 57 | 58 | @property 59 | def datastrip_metadata_href(self) -> Optional[str]: 60 | return self._find_href( 61 | [ 62 | 'dataObject[@ID="S2_Level-1C_Datastrip1_Metadata"]/byteStream/fileLocation', 63 | 'dataObject[@ID="S2_Level-2A_Datastrip1_Metadata"]/byteStream/fileLocation', 64 | ] 65 | ) 66 | 67 | @property 68 | def granule_metadata_href(self) -> Optional[str]: 69 | return self._find_href( 70 | [ 71 | 'dataObject[@ID="S2_Level-2A_Tile1_Data"]/byteStream/fileLocation', 72 | 'dataObject[@ID="S2_Level-1C_Tile1_Metadata"]/byteStream/fileLocation', 73 | 'dataObject[@ID="S2_Level-2A_Tile1_Metadata"]/byteStream/fileLocation', 74 | ] 75 | ) 76 | 77 | def create_asset(self) -> tuple[str, pystac.Asset]: 78 | asset = pystac.Asset( 79 | href=self.href, media_type=pystac.MediaType.XML, roles=["metadata"] 80 | ) 81 | return SAFE_MANIFEST_ASSET_KEY, asset 82 | -------------------------------------------------------------------------------- /src/stactools/sentinel2/tileinfo_metadata.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | from typing import Any, Optional 4 | 5 | import pystac 6 | from pystac.utils import str_to_datetime 7 | from shapely.geometry import shape 8 | 9 | from stactools.core.io import ReadHrefModifier, read_text 10 | from stactools.sentinel2.constants import SENTINEL2_PROPERTY_PREFIX as s2_prefix 11 | from stactools.sentinel2.constants import TILEINFO_METADATA_ASSET_KEY 12 | 13 | 14 | class TileInfoMetadata: 15 | def __init__(self, href, read_href_modifier: Optional[ReadHrefModifier] = None): 16 | self.href = href 17 | self.tileinfo = json.loads(read_text(self.href, read_href_modifier)) 18 | 19 | self._datetime = str_to_datetime(self.tileinfo["timestamp"]) 20 | self._geometry = self.tileinfo.get("tileDataGeometry") 21 | self._bbox = shape(self._geometry).bounds if self._geometry else None 22 | self._product_path = self.tileinfo["productPath"] 23 | 24 | @property 25 | def product_path(self) -> str: 26 | return self._product_path 27 | 28 | @property 29 | def geometry(self) -> dict[str, Any]: 30 | return self._geometry 31 | 32 | @property 33 | def bbox(self) -> tuple[float, float, float, float]: 34 | return self._bbox # type: ignore 35 | 36 | @property 37 | def datetime(self) -> datetime: 38 | return self._datetime 39 | 40 | @property 41 | def metadata_dict(self): 42 | product_type = None 43 | product_name = self.tileinfo.get("productName") 44 | if product_name and "_MSIL2A_" in product_name: 45 | product_type = "S2MSI2A" 46 | elif product_name and "_MSIL1C_" in product_name: 47 | product_type = "S2MSI1C" 48 | 49 | result = {f"{s2_prefix}:product_type": product_type} 50 | 51 | return {k: v for k, v in result.items() if v is not None} 52 | 53 | def create_asset(self): 54 | asset = pystac.Asset( 55 | href=self.href, media_type=pystac.MediaType.JSON, roles=["metadata"] 56 | ) 57 | return TILEINFO_METADATA_ASSET_KEY, asset 58 | -------------------------------------------------------------------------------- /src/stactools/sentinel2/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from re import Pattern 3 | from typing import Final, Optional 4 | 5 | import shapely 6 | from pystac import Item 7 | from shapely.geometry import MultiPolygon, Polygon, shape 8 | 9 | from stactools.core.utils import antimeridian 10 | from stactools.core.utils.antimeridian import Strategy 11 | 12 | GSD_PATTERN: Final[Pattern[str]] = re.compile(r"[_R](\d0)m") 13 | 14 | 15 | def extract_gsd(image_path: str) -> Optional[int]: 16 | match = GSD_PATTERN.search(image_path) 17 | if match: 18 | return int(match.group(1)) 19 | else: 20 | return None 21 | 22 | 23 | def fix_z_values(coord_values: list[str]) -> list[float]: 24 | """Some geometries have a '0' value in the z position 25 | of the coordinates. This method detects and removes z 26 | position coordinates. This assumes that in cases where 27 | the z value is included, it is included for all coordinates. 28 | """ 29 | if len(coord_values) % 3 == 0: 30 | # Check if all 3rd position values are '0' 31 | # Ignore any blank values 32 | third_position_is_zero = [ 33 | x == "0" for i, x in enumerate(coord_values) if i % 3 == 2 and x 34 | ] 35 | 36 | if all(third_position_is_zero): 37 | # Assuming that all 3rd position coordinates are z values 38 | # Remove them. 39 | return [float(c) for i, c in enumerate(coord_values) if i % 3 != 2] 40 | 41 | return [float(c) for c in coord_values if c] 42 | 43 | 44 | def handle_antimeridian(item: Item, antimeridian_strategy: Strategy) -> None: 45 | """Handles some quirks of the antimeridian. 46 | Applies the requested SPLIT or NORMALIZE strategy via the stactools 47 | antimeridian utility. If the geometry is already SPLIT (a MultiPolygon, 48 | which can occur when using USGS geometry), a merged polygon with different 49 | longitude signs is created to match the expected input of the fix_item 50 | function. 51 | Args: 52 | item (Item): STAC Item 53 | antimeridian_strategy (Antimeridian): Either split on +/-180 or 54 | normalize geometries so all longitudes are either positive or 55 | negative. 56 | """ 57 | geometry = shape(item.geometry) 58 | if isinstance(geometry, MultiPolygon): 59 | # force all positive lons so we can merge on an antimeridian split 60 | polys = list(geometry.geoms) 61 | for index, poly in enumerate(polys): 62 | coords = list(poly.exterior.coords) 63 | lons = [coord[0] for coord in coords] 64 | if min(lons) < 0: 65 | polys[index] = shapely.affinity.translate(poly, xoff=+360) 66 | merged_geometry = shapely.ops.unary_union(polys) 67 | 68 | # revert back to + and - lon signs for fix_item's expected input 69 | merged_coords = list(merged_geometry.exterior.coords) 70 | for index, coord in enumerate(merged_coords): 71 | if coord[0] > 180: 72 | merged_coords[index] = (coord[0] - 360, coord[1]) 73 | item.geometry = Polygon(merged_coords) 74 | 75 | antimeridian.fix_item(item, antimeridian_strategy) 76 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from stactools.testing import TestData 2 | 3 | test_data = TestData(__file__) 4 | -------------------------------------------------------------------------------- /tests/data-files/S2A_MSIL1C_20200717T221941_R029_T01LAC_20200717T234135.SAFE/DATASTRIP/DS_SGS__20200717T234135_S20200717T221944/QI_DATA/GENERAL_QUALITY.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | S2A_OPER_MSI_L1C_DS_SGS__20200717T234135_S20200717T221944_GENERAL_QUALITY_report.xml 6 | Report produced by OLQC-SC 7 | 8 | S2A 9 | OPER 10 | REP_OLQCPA 11 | 12 | UTC=2019-10-20T23:30:00 13 | UTC=2100-01-01T00:00:00 14 | 15 | 2 16 | 17 | OLQC-SC 18 | OLQC-SC 19 | 01.06.04 20 | UTC=2020-07-18T00:10:05 21 | 22 | 23 | 24 | 25 | 26 | 27 | S2A_OPER_GIP_OLQCPA_MPC__20191004T000021_V20191020T233000 28 | GENERAL_QUALITY 29 | 1.0 30 | 31 | 32 | 33 | Metadata file is present. 34 | 35 | 36 | 37 | Sensing orbit number (29) is in range [1,143] 38 | 39 | 29 40 | 41 | 42 | 43 | 44 | Downlink Orbit number (26482) is in range [1,99999] 45 | 46 | 26482 47 | 48 | 49 | 50 | 51 | Fake decompressed source frames are valid. 52 | 53 | 54 | 55 | NUMBER_OF_LOST_PACKETS value 0 does not exceed the threshold (0) 56 | 57 | 0 58 | 59 | 60 | 61 | 62 | Processor version is valid (02.09) 63 | 64 | 02.09 65 | 66 | 67 | 68 | 69 | NUMBER_OF_TOO_DEGRADED_PACKETS value 0 does not exceed the threshold (0) 70 | 71 | 0 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /tests/data-files/S2A_MSIL1C_20200717T221941_R029_T01LAC_20200717T234135.SAFE/DATASTRIP/DS_SGS__20200717T234135_S20200717T221944/QI_DATA/GEOMETRIC_QUALITY.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | S2A_OPER_MSI_L1C_DS_SGS__20200717T234135_S20200717T221944_GEOMETRIC_QUALITY_report.xml 6 | Report produced by OLQC-SC 7 | 8 | S2A 9 | OPER 10 | REP_OLQCPA 11 | 12 | UTC=2019-10-20T23:30:00 13 | UTC=2100-01-01T00:00:00 14 | 15 | 2 16 | 17 | OLQC-SC 18 | OLQC-SC 19 | 01.06.04 20 | UTC=2020-07-18T00:10:05 21 | 22 | 23 | 24 | 25 | 26 | 27 | S2A_OPER_GIP_OLQCPA_MPC__20191004T000021_V20191020T233000 28 | GEOMETRIC_QUALITY 29 | 1.0 30 | 31 | 32 | 33 | Geometry of image has not been refined. 34 | 35 | 36 | 37 | No wrong raw attitude indicators were found. 38 | 39 | 40 | 41 | Ephemeris quality is valid (3.0<=20.0) 42 | 43 | 3.0 44 | 20.0 45 | 46 | 47 | 48 | 49 | Absolute location is valid (20.0<30.0) 50 | 51 | 20.0 52 | 30.0 53 | 54 | 55 | 56 | 57 | VNIR / SWIR bands have not been registered. 58 | 59 | 60 | 61 | No wrong corrected attitude indicators were found. 62 | 63 | 64 | 65 | Planimetric stability is valid (3.0<5.0) 66 | 67 | 3.0 68 | 5.0 69 | 70 | 71 | 72 | 73 | Geometric Spatio Residual Histograms not computed. 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /tests/data-files/S2A_MSIL1C_20200717T221941_R029_T01LAC_20200717T234135.SAFE/DATASTRIP/DS_SGS__20200717T234135_S20200717T221944/QI_DATA/RADIOMETRIC_QUALITY.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | S2A_OPER_MSI_L1C_DS_SGS__20200717T234135_S20200717T221944_RADIOMETRIC_QUALITY_report.xml 6 | Report produced by OLQC-SC 7 | 8 | S2A 9 | OPER 10 | REP_OLQCPA 11 | 12 | UTC=2019-10-20T23:30:00 13 | UTC=2100-01-01T00:00:00 14 | 15 | 2 16 | 17 | OLQC-SC 18 | OLQC-SC 19 | 01.06.04 20 | UTC=2020-07-18T00:10:05 21 | 22 | 23 | 24 | 25 | 26 | 27 | S2A_OPER_GIP_OLQCPA_MPC__20191004T000021_V20191020T233000 28 | RADIOMETRIC_QUALITY 29 | 1.0 30 | 31 | 32 | 33 | Multi-temporal calibration accuracy is correct. 34 | 35 | 36 | 37 | INTEGRATION_TIME is OK 38 | 39 | 40 | 41 | SOLAR RADIANCE is OK 42 | 43 | 44 | 45 | Equalization Mode is set to TRUE 46 | 47 | 48 | 49 | Cross band calibration accuracy is correct. 50 | 51 | 52 | 53 | QUANTIFICATION_VALUE is OK 54 | 55 | 56 | 57 | Absolute calibration accuracy is correct. 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /tests/data-files/S2A_MSIL1C_20200717T221941_R029_T01LAC_20200717T234135.SAFE/DATASTRIP/DS_SGS__20200717T234135_S20200717T221944/QI_DATA/SENSOR_QUALITY.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | S2A_OPER_MSI_L1C_DS_SGS__20200717T234135_S20200717T221944_SENSOR_QUALITY_report.xml 6 | Report produced by OLQC-SC 7 | 8 | S2A 9 | OPER 10 | REP_OLQCPA 11 | 12 | UTC=2019-10-20T23:30:00 13 | UTC=2100-01-01T00:00:00 14 | 15 | 2 16 | 17 | OLQC-SC 18 | OLQC-SC 19 | 01.06.04 20 | UTC=2020-07-18T00:10:05 21 | 22 | 23 | 24 | 25 | 26 | 27 | S2A_OPER_GIP_OLQCPA_MPC__20191004T000021_V20191020T233000 28 | SENSOR_QUALITY 29 | 1.0 30 | 31 | 32 | 33 | Metadata file is present. 34 | 35 | 36 | 37 | No Degraded SAD 38 | 39 | 40 | 41 | GPS is synchronized. 42 | 43 | 44 | 45 | RMOY not found in dataset 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /tests/data-files/S2A_MSIL1C_20210908T042701_N0301_R133_T46RER_20210908T070248.SAFE/expected_output.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Feature", 3 | "stac_version": "1.0.0", 4 | "id": "S2A_T46RER_20210908T043714_L1C", 5 | "properties": { 6 | "created": "2025-06-04T00:16:08.651432Z", 7 | "providers": [ 8 | { 9 | "name": "ESA", 10 | "roles": [ 11 | "producer", 12 | "processor", 13 | "licensor" 14 | ], 15 | "url": "https://earth.esa.int/web/guest/home" 16 | } 17 | ], 18 | "platform": "sentinel-2a", 19 | "constellation": "sentinel-2", 20 | "instruments": [ 21 | "msi" 22 | ], 23 | "eo:cloud_cover": 88.2972, 24 | "eo:snow_cover": 88.2972, 25 | "sat:orbit_state": "descending", 26 | "sat:relative_orbit": 133, 27 | "proj:epsg": 32646, 28 | "proj:centroid": { 29 | "lat": 27.60572, 30 | "lon": 93.15775 31 | }, 32 | "mgrs:utm_zone": 46, 33 | "mgrs:latitude_band": "R", 34 | "mgrs:grid_square": "ER", 35 | "grid:code": "MGRS-46RER", 36 | "view:azimuth": 288.30815613430536, 37 | "view:incidence_angle": 10.584880548367346, 38 | "view:sun_azimuth": 142.987598836457, 39 | "view:sun_elevation": 63.5068357330561, 40 | "s2:product_uri": "S2A_MSIL1C_20210908T042701_N0301_R133_T46RER_20210908T070248.SAFE", 41 | "s2:generation_time": "2021-09-08T07:02:48.000000Z", 42 | "s2:processing_baseline": "03.01", 43 | "s2:product_type": "S2MSI1C", 44 | "s2:datatake_id": "GS2A_20210908T042701_032448_N03.01", 45 | "s2:datatake_type": "INS-NOBS", 46 | "s2:datastrip_id": "S2A_OPER_MSI_L1C_DS_VGS4_20210908T070248_S20210908T043714_N03.01", 47 | "s2:tile_id": "S2A_OPER_MSI_L1C_TL_VGS4_20210908T070248_A032448_T46RER_N03.01", 48 | "s2:reflectance_conversion_factor": 0.983841990384341, 49 | "s2:degraded_msi_data_percentage": 0.0, 50 | "datetime": "2021-09-08T04:27:01.024000Z" 51 | }, 52 | "geometry": { 53 | "type": "Polygon", 54 | "coordinates": [ 55 | [ 56 | [ 57 | 93.43341800000002, 58 | 28.023674 59 | ], 60 | [ 61 | 92.999797, 62 | 28.025436 63 | ], 64 | [ 65 | 92.999798, 66 | 27.034171 67 | ], 68 | [ 69 | 93.16090600000001, 70 | 27.033538 71 | ], 72 | [ 73 | 93.19755700000002, 74 | 27.175558 75 | ], 76 | [ 77 | 93.23722600000002, 78 | 27.323598 79 | ], 80 | [ 81 | 93.27838199999997, 82 | 27.471307 83 | ], 84 | [ 85 | 93.316957, 86 | 27.619769 87 | ], 88 | [ 89 | 93.36091799999997, 90 | 27.766687 91 | ], 92 | [ 93 | 93.40196500000002, 94 | 27.914368 95 | ], 96 | [ 97 | 93.43341800000002, 98 | 28.023674 99 | ] 100 | ] 101 | ] 102 | }, 103 | "links": [ 104 | { 105 | "rel": "license", 106 | "href": "https://sentinel.esa.int/documents/247904/690755/Sentinel_Data_Legal_Notice" 107 | } 108 | ], 109 | "assets": { 110 | "coastal": { 111 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B01.jp2", 112 | "type": "image/jp2", 113 | "title": "Coastal - 60m", 114 | "eo:bands": [ 115 | { 116 | "name": "B01", 117 | "common_name": "coastal", 118 | "center_wavelength": 0.443, 119 | "full_width_half_max": 0.027 120 | } 121 | ], 122 | "gsd": 60, 123 | "proj:shape": [ 124 | 1830, 125 | 1830 126 | ], 127 | "proj:bbox": [ 128 | 499980.0, 129 | 2990220.0, 130 | 609780.0, 131 | 3100020.0 132 | ], 133 | "proj:transform": [ 134 | 60.0, 135 | 0.0, 136 | 499980.0, 137 | 0.0, 138 | -60.0, 139 | 3100020.0 140 | ], 141 | "raster:bands": [ 142 | { 143 | "nodata": 0, 144 | "data_type": "uint16", 145 | "spatial_resolution": 60, 146 | "scale": 0.0001, 147 | "offset": 0 148 | } 149 | ], 150 | "roles": [ 151 | "data", 152 | "reflectance" 153 | ] 154 | }, 155 | "blue": { 156 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B02.jp2", 157 | "type": "image/jp2", 158 | "title": "Blue - 10m", 159 | "eo:bands": [ 160 | { 161 | "name": "B02", 162 | "common_name": "blue", 163 | "center_wavelength": 0.49, 164 | "full_width_half_max": 0.098 165 | } 166 | ], 167 | "gsd": 10, 168 | "proj:shape": [ 169 | 10980, 170 | 10980 171 | ], 172 | "proj:bbox": [ 173 | 499980.0, 174 | 2990220.0, 175 | 609780.0, 176 | 3100020.0 177 | ], 178 | "proj:transform": [ 179 | 10.0, 180 | 0.0, 181 | 499980.0, 182 | 0.0, 183 | -10.0, 184 | 3100020.0 185 | ], 186 | "raster:bands": [ 187 | { 188 | "nodata": 0, 189 | "data_type": "uint16", 190 | "spatial_resolution": 10, 191 | "scale": 0.0001, 192 | "offset": 0 193 | } 194 | ], 195 | "roles": [ 196 | "data", 197 | "reflectance" 198 | ] 199 | }, 200 | "green": { 201 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B03.jp2", 202 | "type": "image/jp2", 203 | "title": "Green - 10m", 204 | "eo:bands": [ 205 | { 206 | "name": "B03", 207 | "common_name": "green", 208 | "center_wavelength": 0.56, 209 | "full_width_half_max": 0.045 210 | } 211 | ], 212 | "gsd": 10, 213 | "proj:shape": [ 214 | 10980, 215 | 10980 216 | ], 217 | "proj:bbox": [ 218 | 499980.0, 219 | 2990220.0, 220 | 609780.0, 221 | 3100020.0 222 | ], 223 | "proj:transform": [ 224 | 10.0, 225 | 0.0, 226 | 499980.0, 227 | 0.0, 228 | -10.0, 229 | 3100020.0 230 | ], 231 | "raster:bands": [ 232 | { 233 | "nodata": 0, 234 | "data_type": "uint16", 235 | "spatial_resolution": 10, 236 | "scale": 0.0001, 237 | "offset": 0 238 | } 239 | ], 240 | "roles": [ 241 | "data", 242 | "reflectance" 243 | ] 244 | }, 245 | "red": { 246 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B04.jp2", 247 | "type": "image/jp2", 248 | "title": "Red - 10m", 249 | "eo:bands": [ 250 | { 251 | "name": "B04", 252 | "common_name": "red", 253 | "center_wavelength": 0.665, 254 | "full_width_half_max": 0.038 255 | } 256 | ], 257 | "gsd": 10, 258 | "proj:shape": [ 259 | 10980, 260 | 10980 261 | ], 262 | "proj:bbox": [ 263 | 499980.0, 264 | 2990220.0, 265 | 609780.0, 266 | 3100020.0 267 | ], 268 | "proj:transform": [ 269 | 10.0, 270 | 0.0, 271 | 499980.0, 272 | 0.0, 273 | -10.0, 274 | 3100020.0 275 | ], 276 | "raster:bands": [ 277 | { 278 | "nodata": 0, 279 | "data_type": "uint16", 280 | "spatial_resolution": 10, 281 | "scale": 0.0001, 282 | "offset": 0 283 | } 284 | ], 285 | "roles": [ 286 | "data", 287 | "reflectance" 288 | ] 289 | }, 290 | "rededge1": { 291 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B05.jp2", 292 | "type": "image/jp2", 293 | "title": "Red Edge 1 - 20m", 294 | "eo:bands": [ 295 | { 296 | "name": "B05", 297 | "common_name": "rededge", 298 | "center_wavelength": 0.704, 299 | "full_width_half_max": 0.019 300 | } 301 | ], 302 | "gsd": 20, 303 | "proj:shape": [ 304 | 5490, 305 | 5490 306 | ], 307 | "proj:bbox": [ 308 | 499980.0, 309 | 2990220.0, 310 | 609780.0, 311 | 3100020.0 312 | ], 313 | "proj:transform": [ 314 | 20.0, 315 | 0.0, 316 | 499980.0, 317 | 0.0, 318 | -20.0, 319 | 3100020.0 320 | ], 321 | "raster:bands": [ 322 | { 323 | "nodata": 0, 324 | "data_type": "uint16", 325 | "spatial_resolution": 20, 326 | "scale": 0.0001, 327 | "offset": 0 328 | } 329 | ], 330 | "roles": [ 331 | "data", 332 | "reflectance" 333 | ] 334 | }, 335 | "rededge2": { 336 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B06.jp2", 337 | "type": "image/jp2", 338 | "title": "Red Edge 2 - 20m", 339 | "eo:bands": [ 340 | { 341 | "name": "B06", 342 | "common_name": "rededge", 343 | "center_wavelength": 0.74, 344 | "full_width_half_max": 0.018 345 | } 346 | ], 347 | "gsd": 20, 348 | "proj:shape": [ 349 | 5490, 350 | 5490 351 | ], 352 | "proj:bbox": [ 353 | 499980.0, 354 | 2990220.0, 355 | 609780.0, 356 | 3100020.0 357 | ], 358 | "proj:transform": [ 359 | 20.0, 360 | 0.0, 361 | 499980.0, 362 | 0.0, 363 | -20.0, 364 | 3100020.0 365 | ], 366 | "raster:bands": [ 367 | { 368 | "nodata": 0, 369 | "data_type": "uint16", 370 | "spatial_resolution": 20, 371 | "scale": 0.0001, 372 | "offset": 0 373 | } 374 | ], 375 | "roles": [ 376 | "data", 377 | "reflectance" 378 | ] 379 | }, 380 | "rededge3": { 381 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B07.jp2", 382 | "type": "image/jp2", 383 | "title": "Red Edge 3 - 20m", 384 | "eo:bands": [ 385 | { 386 | "name": "B07", 387 | "common_name": "rededge", 388 | "center_wavelength": 0.783, 389 | "full_width_half_max": 0.028 390 | } 391 | ], 392 | "gsd": 20, 393 | "proj:shape": [ 394 | 5490, 395 | 5490 396 | ], 397 | "proj:bbox": [ 398 | 499980.0, 399 | 2990220.0, 400 | 609780.0, 401 | 3100020.0 402 | ], 403 | "proj:transform": [ 404 | 20.0, 405 | 0.0, 406 | 499980.0, 407 | 0.0, 408 | -20.0, 409 | 3100020.0 410 | ], 411 | "raster:bands": [ 412 | { 413 | "nodata": 0, 414 | "data_type": "uint16", 415 | "spatial_resolution": 20, 416 | "scale": 0.0001, 417 | "offset": 0 418 | } 419 | ], 420 | "roles": [ 421 | "data", 422 | "reflectance" 423 | ] 424 | }, 425 | "nir": { 426 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B08.jp2", 427 | "type": "image/jp2", 428 | "title": "NIR 1 - 10m", 429 | "eo:bands": [ 430 | { 431 | "name": "B08", 432 | "common_name": "nir", 433 | "center_wavelength": 0.842, 434 | "full_width_half_max": 0.145 435 | } 436 | ], 437 | "gsd": 10, 438 | "proj:shape": [ 439 | 10980, 440 | 10980 441 | ], 442 | "proj:bbox": [ 443 | 499980.0, 444 | 2990220.0, 445 | 609780.0, 446 | 3100020.0 447 | ], 448 | "proj:transform": [ 449 | 10.0, 450 | 0.0, 451 | 499980.0, 452 | 0.0, 453 | -10.0, 454 | 3100020.0 455 | ], 456 | "raster:bands": [ 457 | { 458 | "nodata": 0, 459 | "data_type": "uint16", 460 | "spatial_resolution": 10, 461 | "scale": 0.0001, 462 | "offset": 0 463 | } 464 | ], 465 | "roles": [ 466 | "data", 467 | "reflectance" 468 | ] 469 | }, 470 | "nir08": { 471 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B8A.jp2", 472 | "type": "image/jp2", 473 | "title": "NIR 2 - 20m", 474 | "eo:bands": [ 475 | { 476 | "name": "B8A", 477 | "common_name": "nir08", 478 | "center_wavelength": 0.865, 479 | "full_width_half_max": 0.033 480 | } 481 | ], 482 | "gsd": 20, 483 | "proj:shape": [ 484 | 5490, 485 | 5490 486 | ], 487 | "proj:bbox": [ 488 | 499980.0, 489 | 2990220.0, 490 | 609780.0, 491 | 3100020.0 492 | ], 493 | "proj:transform": [ 494 | 20.0, 495 | 0.0, 496 | 499980.0, 497 | 0.0, 498 | -20.0, 499 | 3100020.0 500 | ], 501 | "raster:bands": [ 502 | { 503 | "nodata": 0, 504 | "data_type": "uint16", 505 | "spatial_resolution": 20, 506 | "scale": 0.0001, 507 | "offset": 0 508 | } 509 | ], 510 | "roles": [ 511 | "data", 512 | "reflectance" 513 | ] 514 | }, 515 | "nir09": { 516 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B09.jp2", 517 | "type": "image/jp2", 518 | "title": "NIR 3 - 60m", 519 | "eo:bands": [ 520 | { 521 | "name": "B09", 522 | "common_name": "nir09", 523 | "center_wavelength": 0.945, 524 | "full_width_half_max": 0.026 525 | } 526 | ], 527 | "gsd": 60, 528 | "proj:shape": [ 529 | 1830, 530 | 1830 531 | ], 532 | "proj:bbox": [ 533 | 499980.0, 534 | 2990220.0, 535 | 609780.0, 536 | 3100020.0 537 | ], 538 | "proj:transform": [ 539 | 60.0, 540 | 0.0, 541 | 499980.0, 542 | 0.0, 543 | -60.0, 544 | 3100020.0 545 | ], 546 | "raster:bands": [ 547 | { 548 | "nodata": 0, 549 | "data_type": "uint16", 550 | "spatial_resolution": 60, 551 | "scale": 0.0001, 552 | "offset": 0 553 | } 554 | ], 555 | "roles": [ 556 | "data", 557 | "reflectance" 558 | ] 559 | }, 560 | "cirrus": { 561 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B10.jp2", 562 | "type": "image/jp2", 563 | "title": "Cirrus - 60m", 564 | "eo:bands": [ 565 | { 566 | "name": "B10", 567 | "common_name": "cirrus", 568 | "center_wavelength": 1.3735, 569 | "full_width_half_max": 0.075 570 | } 571 | ], 572 | "gsd": 60, 573 | "proj:shape": [ 574 | 1830, 575 | 1830 576 | ], 577 | "proj:bbox": [ 578 | 499980.0, 579 | 2990220.0, 580 | 609780.0, 581 | 3100020.0 582 | ], 583 | "proj:transform": [ 584 | 60.0, 585 | 0.0, 586 | 499980.0, 587 | 0.0, 588 | -60.0, 589 | 3100020.0 590 | ], 591 | "raster:bands": [ 592 | { 593 | "nodata": 0, 594 | "data_type": "uint16", 595 | "spatial_resolution": 60, 596 | "scale": 0.0001, 597 | "offset": 0 598 | } 599 | ], 600 | "roles": [ 601 | "data", 602 | "reflectance" 603 | ] 604 | }, 605 | "swir16": { 606 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B11.jp2", 607 | "type": "image/jp2", 608 | "title": "SWIR 1.6\u03bcm - 20m", 609 | "eo:bands": [ 610 | { 611 | "name": "B11", 612 | "common_name": "swir16", 613 | "center_wavelength": 1.61, 614 | "full_width_half_max": 0.143 615 | } 616 | ], 617 | "gsd": 20, 618 | "proj:shape": [ 619 | 5490, 620 | 5490 621 | ], 622 | "proj:bbox": [ 623 | 499980.0, 624 | 2990220.0, 625 | 609780.0, 626 | 3100020.0 627 | ], 628 | "proj:transform": [ 629 | 20.0, 630 | 0.0, 631 | 499980.0, 632 | 0.0, 633 | -20.0, 634 | 3100020.0 635 | ], 636 | "raster:bands": [ 637 | { 638 | "nodata": 0, 639 | "data_type": "uint16", 640 | "spatial_resolution": 20, 641 | "scale": 0.0001, 642 | "offset": 0 643 | } 644 | ], 645 | "roles": [ 646 | "data", 647 | "reflectance" 648 | ] 649 | }, 650 | "swir22": { 651 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_B12.jp2", 652 | "type": "image/jp2", 653 | "title": "SWIR 2.2\u03bcm - 20m", 654 | "eo:bands": [ 655 | { 656 | "name": "B12", 657 | "common_name": "swir22", 658 | "center_wavelength": 2.19, 659 | "full_width_half_max": 0.242 660 | } 661 | ], 662 | "gsd": 20, 663 | "proj:shape": [ 664 | 5490, 665 | 5490 666 | ], 667 | "proj:bbox": [ 668 | 499980.0, 669 | 2990220.0, 670 | 609780.0, 671 | 3100020.0 672 | ], 673 | "proj:transform": [ 674 | 20.0, 675 | 0.0, 676 | 499980.0, 677 | 0.0, 678 | -20.0, 679 | 3100020.0 680 | ], 681 | "raster:bands": [ 682 | { 683 | "nodata": 0, 684 | "data_type": "uint16", 685 | "spatial_resolution": 20, 686 | "scale": 0.0001, 687 | "offset": 0 688 | } 689 | ], 690 | "roles": [ 691 | "data", 692 | "reflectance" 693 | ] 694 | }, 695 | "visual": { 696 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/IMG_DATA/T46RER_20210908T042701_TCI.jp2", 697 | "type": "image/jp2", 698 | "title": "True color image", 699 | "eo:bands": [ 700 | { 701 | "name": "B04", 702 | "common_name": "red", 703 | "center_wavelength": 0.665, 704 | "full_width_half_max": 0.038 705 | }, 706 | { 707 | "name": "B03", 708 | "common_name": "green", 709 | "center_wavelength": 0.56, 710 | "full_width_half_max": 0.045 711 | }, 712 | { 713 | "name": "B02", 714 | "common_name": "blue", 715 | "center_wavelength": 0.49, 716 | "full_width_half_max": 0.098 717 | } 718 | ], 719 | "raster:bands": [ 720 | { 721 | "nodata": 0, 722 | "data_type": "uint8", 723 | "spatial_resolution": 10 724 | }, 725 | { 726 | "nodata": 0, 727 | "data_type": "uint8", 728 | "spatial_resolution": 10 729 | }, 730 | { 731 | "nodata": 0, 732 | "data_type": "uint8", 733 | "spatial_resolution": 10 734 | } 735 | ], 736 | "proj:shape": [ 737 | 10980, 738 | 10980 739 | ], 740 | "proj:bbox": [ 741 | 499980.0, 742 | 2990220.0, 743 | 609780.0, 744 | 3100020.0 745 | ], 746 | "proj:transform": [ 747 | 10.0, 748 | 0.0, 749 | 499980.0, 750 | 0.0, 751 | -10.0, 752 | 3100020.0 753 | ], 754 | "roles": [ 755 | "visual" 756 | ] 757 | }, 758 | "safe_manifest": { 759 | "href": "./manifest.safe", 760 | "type": "application/xml", 761 | "roles": [ 762 | "metadata" 763 | ] 764 | }, 765 | "product_metadata": { 766 | "href": "./MTD_MSIL1C.xml", 767 | "type": "application/xml", 768 | "roles": [ 769 | "metadata" 770 | ] 771 | }, 772 | "granule_metadata": { 773 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/MTD_TL.xml", 774 | "type": "application/xml", 775 | "roles": [ 776 | "metadata" 777 | ] 778 | }, 779 | "inspire_metadata": { 780 | "href": "./INSPIRE.xml", 781 | "type": "application/xml", 782 | "roles": [ 783 | "metadata" 784 | ] 785 | }, 786 | "datastrip_metadata": { 787 | "href": "./DATASTRIP/DS_VGS4_20210908T070248_S20210908T043714/MTD_DS.xml", 788 | "type": "application/xml", 789 | "roles": [ 790 | "metadata" 791 | ] 792 | }, 793 | "preview": { 794 | "href": "./GRANULE/L1C_T46RER_A032448_20210908T043714/QI_DATA/T46RER_20210908T042701_PVI.jp2", 795 | "type": "image/jp2", 796 | "title": "True color preview", 797 | "gsd": 320, 798 | "proj:shape": [ 799 | 343, 800 | 343 801 | ], 802 | "proj:bbox": [ 803 | 499980.0, 804 | 2990260.0, 805 | 609740.0, 806 | 3100020.0 807 | ], 808 | "proj:transform": [ 809 | 320.0, 810 | 0.0, 811 | 499980.0, 812 | 0.0, 813 | -320.0, 814 | 3100020.0 815 | ], 816 | "roles": [ 817 | "overview" 818 | ] 819 | } 820 | }, 821 | "bbox": [ 822 | 92.999797, 823 | 27.033538, 824 | 93.433418, 825 | 28.025436 826 | ], 827 | "stac_extensions": [ 828 | "https://stac-extensions.github.io/eo/v1.1.0/schema.json", 829 | "https://stac-extensions.github.io/raster/v1.1.0/schema.json", 830 | "https://stac-extensions.github.io/sat/v1.0.0/schema.json", 831 | "https://stac-extensions.github.io/projection/v1.1.0/schema.json", 832 | "https://stac-extensions.github.io/mgrs/v1.0.0/schema.json", 833 | "https://stac-extensions.github.io/grid/v1.1.0/schema.json", 834 | "https://stac-extensions.github.io/view/v1.0.0/schema.json", 835 | "https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json" 836 | ] 837 | } -------------------------------------------------------------------------------- /tests/data-files/S2A_OPER_MSI_L1C_TL_SGS__20181231T203637_A018414_T10SDG/expected_output.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Feature", 3 | "stac_version": "1.0.0", 4 | "id": "S2A_OPER_MSI_L1C_TL_SGS__20181231T203637_A018414_T10SDG", 5 | "properties": { 6 | "created": "2025-05-20T23:07:27.413541Z", 7 | "providers": [ 8 | { 9 | "name": "ESA", 10 | "roles": [ 11 | "producer", 12 | "processor", 13 | "licensor" 14 | ], 15 | "url": "https://earth.esa.int/web/guest/home" 16 | } 17 | ], 18 | "platform": "sentinel-2a", 19 | "constellation": "sentinel-2", 20 | "instruments": [ 21 | "msi" 22 | ], 23 | "eo:cloud_cover": 0.0138, 24 | "proj:epsg": 32610, 25 | "proj:centroid": { 26 | "lat": 37.42015, 27 | "lon": -123.26376 28 | }, 29 | "mgrs:utm_zone": 10, 30 | "mgrs:latitude_band": "S", 31 | "mgrs:grid_square": "DG", 32 | "grid:code": "MGRS-10SDG", 33 | "view:azimuth": 104.53625861291239, 34 | "view:incidence_angle": 9.246039496241822, 35 | "view:sun_azimuth": 161.105709274255, 36 | "view:sun_elevation": 27.099070943431997, 37 | "s2:tile_id": "S2A_OPER_MSI_L1C_TL_SGS__20181231T203637_A018414_T10SDG_N02.07", 38 | "s2:degraded_msi_data_percentage": 0.0, 39 | "s2:product_type": "S2MSI1C", 40 | "s2:processing_baseline": "02.07", 41 | "datetime": "2018-12-31T19:04:06.567000Z" 42 | }, 43 | "geometry": { 44 | "type": "Polygon", 45 | "coordinates": [ 46 | [ 47 | [ 48 | -122.89037933316479, 49 | 36.957839512410274 50 | ], 51 | [ 52 | -122.88892538947995, 53 | 37.94752813015373 54 | ], 55 | [ 56 | -123.4859822086021, 57 | 37.946576907174055 58 | ], 59 | [ 60 | -123.77120008096219, 61 | 36.95538583284048 62 | ], 63 | [ 64 | -122.89037933316479, 65 | 36.957839512410274 66 | ] 67 | ] 68 | ] 69 | }, 70 | "links": [ 71 | { 72 | "rel": "license", 73 | "href": "https://sentinel.esa.int/documents/247904/690755/Sentinel_Data_Legal_Notice" 74 | } 75 | ], 76 | "assets": { 77 | "coastal": { 78 | "href": "./B01.jp2", 79 | "type": "image/jp2", 80 | "title": "Coastal - 60m", 81 | "eo:bands": [ 82 | { 83 | "name": "B01", 84 | "common_name": "coastal", 85 | "center_wavelength": 0.443, 86 | "full_width_half_max": 0.027 87 | } 88 | ], 89 | "gsd": 60, 90 | "proj:shape": [ 91 | 1830, 92 | 1830 93 | ], 94 | "proj:bbox": [ 95 | 399960.0, 96 | 4090200.0, 97 | 509760.0, 98 | 4200000.0 99 | ], 100 | "proj:transform": [ 101 | 60.0, 102 | 0.0, 103 | 399960.0, 104 | 0.0, 105 | -60.0, 106 | 4200000.0 107 | ], 108 | "raster:bands": [ 109 | { 110 | "nodata": 0, 111 | "data_type": "uint16", 112 | "spatial_resolution": 60, 113 | "scale": 0.0001, 114 | "offset": 0 115 | } 116 | ], 117 | "roles": [ 118 | "data", 119 | "reflectance" 120 | ] 121 | }, 122 | "blue": { 123 | "href": "./B02.jp2", 124 | "type": "image/jp2", 125 | "title": "Blue - 10m", 126 | "eo:bands": [ 127 | { 128 | "name": "B02", 129 | "common_name": "blue", 130 | "center_wavelength": 0.49, 131 | "full_width_half_max": 0.098 132 | } 133 | ], 134 | "gsd": 10, 135 | "proj:shape": [ 136 | 10980, 137 | 10980 138 | ], 139 | "proj:bbox": [ 140 | 399960.0, 141 | 4090200.0, 142 | 509760.0, 143 | 4200000.0 144 | ], 145 | "proj:transform": [ 146 | 10.0, 147 | 0.0, 148 | 399960.0, 149 | 0.0, 150 | -10.0, 151 | 4200000.0 152 | ], 153 | "raster:bands": [ 154 | { 155 | "nodata": 0, 156 | "data_type": "uint16", 157 | "spatial_resolution": 10, 158 | "scale": 0.0001, 159 | "offset": 0 160 | } 161 | ], 162 | "roles": [ 163 | "data", 164 | "reflectance" 165 | ] 166 | }, 167 | "green": { 168 | "href": "./B03.jp2", 169 | "type": "image/jp2", 170 | "title": "Green - 10m", 171 | "eo:bands": [ 172 | { 173 | "name": "B03", 174 | "common_name": "green", 175 | "center_wavelength": 0.56, 176 | "full_width_half_max": 0.045 177 | } 178 | ], 179 | "gsd": 10, 180 | "proj:shape": [ 181 | 10980, 182 | 10980 183 | ], 184 | "proj:bbox": [ 185 | 399960.0, 186 | 4090200.0, 187 | 509760.0, 188 | 4200000.0 189 | ], 190 | "proj:transform": [ 191 | 10.0, 192 | 0.0, 193 | 399960.0, 194 | 0.0, 195 | -10.0, 196 | 4200000.0 197 | ], 198 | "raster:bands": [ 199 | { 200 | "nodata": 0, 201 | "data_type": "uint16", 202 | "spatial_resolution": 10, 203 | "scale": 0.0001, 204 | "offset": 0 205 | } 206 | ], 207 | "roles": [ 208 | "data", 209 | "reflectance" 210 | ] 211 | }, 212 | "red": { 213 | "href": "./B04.jp2", 214 | "type": "image/jp2", 215 | "title": "Red - 10m", 216 | "eo:bands": [ 217 | { 218 | "name": "B04", 219 | "common_name": "red", 220 | "center_wavelength": 0.665, 221 | "full_width_half_max": 0.038 222 | } 223 | ], 224 | "gsd": 10, 225 | "proj:shape": [ 226 | 10980, 227 | 10980 228 | ], 229 | "proj:bbox": [ 230 | 399960.0, 231 | 4090200.0, 232 | 509760.0, 233 | 4200000.0 234 | ], 235 | "proj:transform": [ 236 | 10.0, 237 | 0.0, 238 | 399960.0, 239 | 0.0, 240 | -10.0, 241 | 4200000.0 242 | ], 243 | "raster:bands": [ 244 | { 245 | "nodata": 0, 246 | "data_type": "uint16", 247 | "spatial_resolution": 10, 248 | "scale": 0.0001, 249 | "offset": 0 250 | } 251 | ], 252 | "roles": [ 253 | "data", 254 | "reflectance" 255 | ] 256 | }, 257 | "rededge1": { 258 | "href": "./B05.jp2", 259 | "type": "image/jp2", 260 | "title": "Red Edge 1 - 20m", 261 | "eo:bands": [ 262 | { 263 | "name": "B05", 264 | "common_name": "rededge", 265 | "center_wavelength": 0.704, 266 | "full_width_half_max": 0.019 267 | } 268 | ], 269 | "gsd": 20, 270 | "proj:shape": [ 271 | 5490, 272 | 5490 273 | ], 274 | "proj:bbox": [ 275 | 399960.0, 276 | 4090200.0, 277 | 509760.0, 278 | 4200000.0 279 | ], 280 | "proj:transform": [ 281 | 20.0, 282 | 0.0, 283 | 399960.0, 284 | 0.0, 285 | -20.0, 286 | 4200000.0 287 | ], 288 | "raster:bands": [ 289 | { 290 | "nodata": 0, 291 | "data_type": "uint16", 292 | "spatial_resolution": 20, 293 | "scale": 0.0001, 294 | "offset": 0 295 | } 296 | ], 297 | "roles": [ 298 | "data", 299 | "reflectance" 300 | ] 301 | }, 302 | "rededge2": { 303 | "href": "./B06.jp2", 304 | "type": "image/jp2", 305 | "title": "Red Edge 2 - 20m", 306 | "eo:bands": [ 307 | { 308 | "name": "B06", 309 | "common_name": "rededge", 310 | "center_wavelength": 0.74, 311 | "full_width_half_max": 0.018 312 | } 313 | ], 314 | "gsd": 20, 315 | "proj:shape": [ 316 | 5490, 317 | 5490 318 | ], 319 | "proj:bbox": [ 320 | 399960.0, 321 | 4090200.0, 322 | 509760.0, 323 | 4200000.0 324 | ], 325 | "proj:transform": [ 326 | 20.0, 327 | 0.0, 328 | 399960.0, 329 | 0.0, 330 | -20.0, 331 | 4200000.0 332 | ], 333 | "raster:bands": [ 334 | { 335 | "nodata": 0, 336 | "data_type": "uint16", 337 | "spatial_resolution": 20, 338 | "scale": 0.0001, 339 | "offset": 0 340 | } 341 | ], 342 | "roles": [ 343 | "data", 344 | "reflectance" 345 | ] 346 | }, 347 | "rededge3": { 348 | "href": "./B07.jp2", 349 | "type": "image/jp2", 350 | "title": "Red Edge 3 - 20m", 351 | "eo:bands": [ 352 | { 353 | "name": "B07", 354 | "common_name": "rededge", 355 | "center_wavelength": 0.783, 356 | "full_width_half_max": 0.028 357 | } 358 | ], 359 | "gsd": 20, 360 | "proj:shape": [ 361 | 5490, 362 | 5490 363 | ], 364 | "proj:bbox": [ 365 | 399960.0, 366 | 4090200.0, 367 | 509760.0, 368 | 4200000.0 369 | ], 370 | "proj:transform": [ 371 | 20.0, 372 | 0.0, 373 | 399960.0, 374 | 0.0, 375 | -20.0, 376 | 4200000.0 377 | ], 378 | "raster:bands": [ 379 | { 380 | "nodata": 0, 381 | "data_type": "uint16", 382 | "spatial_resolution": 20, 383 | "scale": 0.0001, 384 | "offset": 0 385 | } 386 | ], 387 | "roles": [ 388 | "data", 389 | "reflectance" 390 | ] 391 | }, 392 | "nir": { 393 | "href": "./B08.jp2", 394 | "type": "image/jp2", 395 | "title": "NIR 1 - 10m", 396 | "eo:bands": [ 397 | { 398 | "name": "B08", 399 | "common_name": "nir", 400 | "center_wavelength": 0.842, 401 | "full_width_half_max": 0.145 402 | } 403 | ], 404 | "gsd": 10, 405 | "proj:shape": [ 406 | 10980, 407 | 10980 408 | ], 409 | "proj:bbox": [ 410 | 399960.0, 411 | 4090200.0, 412 | 509760.0, 413 | 4200000.0 414 | ], 415 | "proj:transform": [ 416 | 10.0, 417 | 0.0, 418 | 399960.0, 419 | 0.0, 420 | -10.0, 421 | 4200000.0 422 | ], 423 | "raster:bands": [ 424 | { 425 | "nodata": 0, 426 | "data_type": "uint16", 427 | "spatial_resolution": 10, 428 | "scale": 0.0001, 429 | "offset": 0 430 | } 431 | ], 432 | "roles": [ 433 | "data", 434 | "reflectance" 435 | ] 436 | }, 437 | "nir08": { 438 | "href": "./B8A.jp2", 439 | "type": "image/jp2", 440 | "title": "NIR 2 - 20m", 441 | "eo:bands": [ 442 | { 443 | "name": "B8A", 444 | "common_name": "nir08", 445 | "center_wavelength": 0.865, 446 | "full_width_half_max": 0.033 447 | } 448 | ], 449 | "gsd": 20, 450 | "proj:shape": [ 451 | 5490, 452 | 5490 453 | ], 454 | "proj:bbox": [ 455 | 399960.0, 456 | 4090200.0, 457 | 509760.0, 458 | 4200000.0 459 | ], 460 | "proj:transform": [ 461 | 20.0, 462 | 0.0, 463 | 399960.0, 464 | 0.0, 465 | -20.0, 466 | 4200000.0 467 | ], 468 | "raster:bands": [ 469 | { 470 | "nodata": 0, 471 | "data_type": "uint16", 472 | "spatial_resolution": 20, 473 | "scale": 0.0001, 474 | "offset": 0 475 | } 476 | ], 477 | "roles": [ 478 | "data", 479 | "reflectance" 480 | ] 481 | }, 482 | "nir09": { 483 | "href": "./B09.jp2", 484 | "type": "image/jp2", 485 | "title": "NIR 3 - 60m", 486 | "eo:bands": [ 487 | { 488 | "name": "B09", 489 | "common_name": "nir09", 490 | "center_wavelength": 0.945, 491 | "full_width_half_max": 0.026 492 | } 493 | ], 494 | "gsd": 60, 495 | "proj:shape": [ 496 | 1830, 497 | 1830 498 | ], 499 | "proj:bbox": [ 500 | 399960.0, 501 | 4090200.0, 502 | 509760.0, 503 | 4200000.0 504 | ], 505 | "proj:transform": [ 506 | 60.0, 507 | 0.0, 508 | 399960.0, 509 | 0.0, 510 | -60.0, 511 | 4200000.0 512 | ], 513 | "raster:bands": [ 514 | { 515 | "nodata": 0, 516 | "data_type": "uint16", 517 | "spatial_resolution": 60, 518 | "scale": 0.0001, 519 | "offset": 0 520 | } 521 | ], 522 | "roles": [ 523 | "data", 524 | "reflectance" 525 | ] 526 | }, 527 | "cirrus": { 528 | "href": "./B10.jp2", 529 | "type": "image/jp2", 530 | "title": "Cirrus - 60m", 531 | "eo:bands": [ 532 | { 533 | "name": "B10", 534 | "common_name": "cirrus", 535 | "center_wavelength": 1.3735, 536 | "full_width_half_max": 0.075 537 | } 538 | ], 539 | "gsd": 60, 540 | "proj:shape": [ 541 | 1830, 542 | 1830 543 | ], 544 | "proj:bbox": [ 545 | 399960.0, 546 | 4090200.0, 547 | 509760.0, 548 | 4200000.0 549 | ], 550 | "proj:transform": [ 551 | 60.0, 552 | 0.0, 553 | 399960.0, 554 | 0.0, 555 | -60.0, 556 | 4200000.0 557 | ], 558 | "raster:bands": [ 559 | { 560 | "nodata": 0, 561 | "data_type": "uint16", 562 | "spatial_resolution": 60, 563 | "scale": 0.0001, 564 | "offset": 0 565 | } 566 | ], 567 | "roles": [ 568 | "data", 569 | "reflectance" 570 | ] 571 | }, 572 | "swir16": { 573 | "href": "./B11.jp2", 574 | "type": "image/jp2", 575 | "title": "SWIR 1.6\u03bcm - 20m", 576 | "eo:bands": [ 577 | { 578 | "name": "B11", 579 | "common_name": "swir16", 580 | "center_wavelength": 1.61, 581 | "full_width_half_max": 0.143 582 | } 583 | ], 584 | "gsd": 20, 585 | "proj:shape": [ 586 | 5490, 587 | 5490 588 | ], 589 | "proj:bbox": [ 590 | 399960.0, 591 | 4090200.0, 592 | 509760.0, 593 | 4200000.0 594 | ], 595 | "proj:transform": [ 596 | 20.0, 597 | 0.0, 598 | 399960.0, 599 | 0.0, 600 | -20.0, 601 | 4200000.0 602 | ], 603 | "raster:bands": [ 604 | { 605 | "nodata": 0, 606 | "data_type": "uint16", 607 | "spatial_resolution": 20, 608 | "scale": 0.0001, 609 | "offset": 0 610 | } 611 | ], 612 | "roles": [ 613 | "data", 614 | "reflectance" 615 | ] 616 | }, 617 | "swir22": { 618 | "href": "./B12.jp2", 619 | "type": "image/jp2", 620 | "title": "SWIR 2.2\u03bcm - 20m", 621 | "eo:bands": [ 622 | { 623 | "name": "B12", 624 | "common_name": "swir22", 625 | "center_wavelength": 2.19, 626 | "full_width_half_max": 0.242 627 | } 628 | ], 629 | "gsd": 20, 630 | "proj:shape": [ 631 | 5490, 632 | 5490 633 | ], 634 | "proj:bbox": [ 635 | 399960.0, 636 | 4090200.0, 637 | 509760.0, 638 | 4200000.0 639 | ], 640 | "proj:transform": [ 641 | 20.0, 642 | 0.0, 643 | 399960.0, 644 | 0.0, 645 | -20.0, 646 | 4200000.0 647 | ], 648 | "raster:bands": [ 649 | { 650 | "nodata": 0, 651 | "data_type": "uint16", 652 | "spatial_resolution": 20, 653 | "scale": 0.0001, 654 | "offset": 0 655 | } 656 | ], 657 | "roles": [ 658 | "data", 659 | "reflectance" 660 | ] 661 | }, 662 | "visual": { 663 | "href": "./TCI.jp2", 664 | "type": "image/jp2", 665 | "title": "True color image", 666 | "eo:bands": [ 667 | { 668 | "name": "B04", 669 | "common_name": "red", 670 | "center_wavelength": 0.665, 671 | "full_width_half_max": 0.038 672 | }, 673 | { 674 | "name": "B03", 675 | "common_name": "green", 676 | "center_wavelength": 0.56, 677 | "full_width_half_max": 0.045 678 | }, 679 | { 680 | "name": "B02", 681 | "common_name": "blue", 682 | "center_wavelength": 0.49, 683 | "full_width_half_max": 0.098 684 | } 685 | ], 686 | "raster:bands": [ 687 | { 688 | "nodata": 0, 689 | "data_type": "uint8", 690 | "spatial_resolution": 10 691 | }, 692 | { 693 | "nodata": 0, 694 | "data_type": "uint8", 695 | "spatial_resolution": 10 696 | }, 697 | { 698 | "nodata": 0, 699 | "data_type": "uint8", 700 | "spatial_resolution": 10 701 | } 702 | ], 703 | "proj:shape": [ 704 | 10980, 705 | 10980 706 | ], 707 | "proj:bbox": [ 708 | 399960.0, 709 | 4090200.0, 710 | 509760.0, 711 | 4200000.0 712 | ], 713 | "proj:transform": [ 714 | 10.0, 715 | 0.0, 716 | 399960.0, 717 | 0.0, 718 | -10.0, 719 | 4200000.0 720 | ], 721 | "roles": [ 722 | "visual" 723 | ] 724 | }, 725 | "granule_metadata": { 726 | "href": "./metadata.xml", 727 | "type": "application/xml", 728 | "roles": [ 729 | "metadata" 730 | ] 731 | }, 732 | "tileinfo_metadata": { 733 | "href": "./tileInfo.json", 734 | "type": "application/json", 735 | "roles": [ 736 | "metadata" 737 | ] 738 | } 739 | }, 740 | "bbox": [ 741 | -123.7712, 742 | 36.955386, 743 | -122.888925, 744 | 37.947528 745 | ], 746 | "stac_extensions": [ 747 | "https://stac-extensions.github.io/eo/v1.1.0/schema.json", 748 | "https://stac-extensions.github.io/raster/v1.1.0/schema.json", 749 | "https://stac-extensions.github.io/projection/v1.1.0/schema.json", 750 | "https://stac-extensions.github.io/mgrs/v1.0.0/schema.json", 751 | "https://stac-extensions.github.io/grid/v1.1.0/schema.json", 752 | "https://stac-extensions.github.io/view/v1.0.0/schema.json", 753 | "https://stac-extensions.github.io/sentinel-2/v1.0.0/schema.json" 754 | ] 755 | } -------------------------------------------------------------------------------- /tests/data-files/S2A_OPER_MSI_L2A_DS_2APS_20230105T201055_S20230105T163809/tileInfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "path" : "tiles/1/C/DL/2023/1/5/0", 3 | "timestamp" : "2023-01-05T16:38:26.276Z", 4 | "utmZone" : 1, 5 | "latitudeBand" : "C", 6 | "gridSquare" : "DL", 7 | "datastrip" : { 8 | "id" : "S2A_OPER_MSI_L2A_DS_2APS_20230105T201055_S20230105T163809_N05.09", 9 | "path" : "products/2023/1/5/S2A_MSIL2A_20230105T163811_N0509_R054_T01CDL_20230105T201055/datastrip/0" 10 | }, 11 | "tileGeometry" : { 12 | "type" : "Polygon", 13 | "crs" : { 14 | "type" : "name", 15 | "properties" : { 16 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32701" 17 | } 18 | }, 19 | "coordinates" : [ [ [ 399960.0, 1100020.0 ], [ 509760.0, 1100020.0 ], [ 509760.0, 990220.0 ], [ 399960.0, 990220.0 ], [ 399960.0, 1100020.0 ] ] ] 20 | }, 21 | "tileDataGeometry" : { 22 | "type" : "Polygon", 23 | "crs" : { 24 | "type" : "name", 25 | "properties" : { 26 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32701" 27 | } 28 | }, 29 | "coordinates" : [ [ [ 442781.0000000098, 1100019.0 ], [ 509759.0, 1100019.0 ], [ 509759.0, 990321.0024685847 ], [ 455792.48379616666, 990440.7949573837 ], [ 454833.84808493464, 1001285.3614406462 ], [ 453643.83400250156, 1001505.3640441381 ], [ 452654.80299753096, 1012670.8940847501 ], [ 451960.7595320532, 1026947.4000140193 ], [ 451600.4379325129, 1030610.6696093443 ], [ 449319.9108131406, 1050595.2888396315 ], [ 446422.8616556521, 1065653.2071349397 ], [ 444813.0208356893, 1076605.7258415895 ], [ 445548.8150592457, 1077325.5944863164 ], [ 445663.09944344853, 1077691.3045157653 ], [ 442781.0, 1098973.9044382232 ], [ 442781.0000000098, 1100019.0 ] ] ] 30 | }, 31 | "tileOrigin" : { 32 | "type" : "Point", 33 | "crs" : { 34 | "type" : "name", 35 | "properties" : { 36 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32701" 37 | } 38 | }, 39 | "coordinates" : [ 399960.0, 1100020.0 ] 40 | }, 41 | "dataCoveragePercentage" : 54.95, 42 | "cloudyPixelPercentage" : 0.0, 43 | "productName" : "S2A_MSIL2A_20230105T163811_N0509_R054_T01CDL_20230105T201055", 44 | "productPath" : "products/2023/1/5/S2A_MSIL2A_20230105T163811_N0509_R054_T01CDL_20230105T201055" 45 | } -------------------------------------------------------------------------------- /tests/data-files/S2A_OPER_MSI_L2A_TL_2APS_20240108T121951_A044635_T34VEL/metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | S2A_OPER_MSI_L1C_TL_2APS_20240108T105807_A044635_T34VEL_N05.10 5 | S2A_OPER_MSI_L2A_TL_2APS_20240108T121951_A044635_T34VEL_N05.10 6 | S2A_OPER_MSI_L2A_DS_2APS_20240108T121951_S20240108T101404_N05.10 7 | NOMINAL 8 | 2024-01-08T10:14:07.485567Z 9 | 10 | 2APS 11 | 2024-01-08T12:34:25.742305Z 12 | 13 | 14 | 15 | 16 | WGS84 / UTM zone 34N 17 | EPSG:32634 18 | 19 | 10980 20 | 10980 21 | 22 | 23 | 5490 24 | 5490 25 | 26 | 27 | 1830 28 | 1830 29 | 30 | 31 | 499980 32 | 6600000 33 | 10 34 | -10 35 | 36 | 37 | 499980 38 | 6600000 39 | 20 40 | -20 41 | 42 | 43 | 499980 44 | 6600000 45 | 60 46 | -60 47 | 48 | 49 | 50 | 51 | 52 | 5000 53 | 5000 54 | 55 | 82.0286 82.0235 82.0183 82.0132 82.0081 82.003 81.9979 81.9928 81.9877 81.9826 81.9775 81.9724 81.9673 81.9622 81.9571 81.952 81.947 81.9419 81.9368 81.9318 81.9267 81.9216 81.9166 56 | 81.9838 81.9787 81.9736 81.9684 81.9633 81.9582 81.9531 81.948 81.9429 81.9378 81.9327 81.9276 81.9225 81.9175 81.9124 81.9073 81.9022 81.8972 81.8921 81.887 81.882 81.8769 81.8719 57 | 81.939 81.9339 81.9288 81.9237 81.9186 81.9135 81.9084 81.9033 81.8981 81.8931 81.888 81.8829 81.8778 81.8727 81.8676 81.8626 81.8575 81.8524 81.8474 81.8423 81.8372 81.8322 81.8271 58 | 81.8943 81.8891 81.884 81.8789 81.8738 81.8687 81.8636 81.8585 81.8534 81.8483 81.8432 81.8381 81.833 81.828 81.8229 81.8178 81.8127 81.8077 81.8026 81.7975 81.7925 81.7874 81.7824 59 | 81.8495 81.8444 81.8393 81.8341 81.829 81.8239 81.8188 81.8137 81.8086 81.8035 81.7984 81.7934 81.7883 81.7832 81.7781 81.7731 81.768 81.7629 81.7579 81.7528 81.7478 81.7427 81.7377 60 | 81.8047 81.7996 81.7945 81.7894 81.7843 81.7792 81.7741 81.769 81.7639 81.7588 81.7537 81.7486 81.7435 81.7384 81.7334 81.7283 81.7232 81.7182 81.7131 81.7081 81.703 81.698 81.6929 61 | 81.7599 81.7548 81.7497 81.7446 81.7395 81.7344 81.7293 81.7242 81.7191 81.714 81.7089 81.7038 81.6988 81.6937 81.6886 81.6836 81.6785 81.6734 81.6684 81.6633 81.6583 81.6532 81.6482 62 | 81.7152 81.71 81.7049 81.6998 81.6947 81.6896 81.6845 81.6794 81.6743 81.6693 81.6642 81.6591 81.654 81.6489 81.6439 81.6388 81.6338 81.6287 81.6236 81.6186 81.6135 81.6085 81.6035 63 | 81.6704 81.6653 81.6602 81.6551 81.65 81.6449 81.6398 81.6347 81.6296 81.6245 81.6194 81.6143 81.6093 81.6042 81.5991 81.5941 81.589 81.5839 81.5789 81.5738 81.5688 81.5638 81.5587 64 | 81.6256 81.6205 81.6154 81.6103 81.6052 81.6001 81.595 81.5899 81.5848 81.5797 81.5747 81.5696 81.5645 81.5594 81.5544 81.5493 81.5443 81.5392 81.5341 81.5291 81.5241 81.519 81.514 65 | 81.5808 81.5757 81.5706 81.5655 81.5604 81.5553 81.5502 81.5451 81.5401 81.535 81.5299 81.5248 81.5198 81.5147 81.5096 81.5046 81.4995 81.4945 81.4894 81.4844 81.4793 81.4743 81.4693 66 | 81.536 81.5309 81.5258 81.5207 81.5156 81.5105 81.5055 81.5004 81.4953 81.4902 81.4851 81.4801 81.475 81.4699 81.4649 81.4598 81.4548 81.4497 81.4447 81.4396 81.4346 81.4295 81.4245 67 | 81.4913 81.4862 81.4811 81.476 81.4709 81.4658 81.4607 81.4556 81.4505 81.4454 81.4404 81.4353 81.4302 81.4252 81.4201 81.4151 81.41 81.405 81.3999 81.3949 81.3898 81.3848 81.3798 68 | 81.4465 81.4414 81.4363 81.4312 81.4261 81.421 81.4159 81.4108 81.4058 81.4007 81.3956 81.3905 81.3855 81.3804 81.3754 81.3703 81.3652 81.3602 81.3552 81.3501 81.3451 81.3401 81.335 69 | 81.4017 81.3966 81.3915 81.3864 81.3813 81.3762 81.3711 81.3661 81.361 81.3559 81.3508 81.3458 81.3407 81.3357 81.3306 81.3255 81.3205 81.3155 81.3104 81.3054 81.3004 81.2953 81.2903 70 | 81.3569 81.3518 81.3467 81.3416 81.3365 81.3315 81.3264 81.3213 81.3162 81.3111 81.3061 81.301 81.296 81.2909 81.2858 81.2808 81.2757 81.2707 81.2657 81.2606 81.2556 81.2506 81.2456 71 | 81.3121 81.307 81.3019 81.2969 81.2918 81.2867 81.2816 81.2765 81.2714 81.2664 81.2613 81.2562 81.2512 81.2461 81.2411 81.236 81.231 81.226 81.2209 81.2159 81.2109 81.2058 81.2008 72 | 81.2674 81.2623 81.2572 81.2521 81.247 81.2419 81.2368 81.2318 81.2267 81.2216 81.2165 81.2115 81.2064 81.2014 81.1963 81.1913 81.1862 81.1812 81.1762 81.1711 81.1661 81.1611 81.1561 73 | 81.2226 81.2175 81.2124 81.2073 81.2022 81.1971 81.1921 81.187 81.1819 81.1768 81.1718 81.1667 81.1617 81.1566 81.1516 81.1465 81.1415 81.1365 81.1314 81.1264 81.1214 81.1163 81.1113 74 | 81.1778 81.1727 81.1676 81.1625 81.1574 81.1524 81.1473 81.1422 81.1371 81.1321 81.127 81.122 81.1169 81.1119 81.1068 81.1018 81.0967 81.0917 81.0867 81.0816 81.0766 81.0716 81.0666 75 | 81.133 81.1279 81.1228 81.1177 81.1127 81.1076 81.1025 81.0974 81.0924 81.0873 81.0822 81.0772 81.0721 81.0671 81.062 81.057 81.052 81.0469 81.0419 81.0369 81.0319 81.0269 81.0218 76 | 81.0882 81.0831 81.078 81.073 81.0679 81.0628 81.0577 81.0527 81.0476 81.0425 81.0375 81.0324 81.0274 81.0223 81.0173 81.0122 81.0072 81.0022 80.9972 80.9921 80.9871 80.9821 80.9771 77 | 81.0434 81.0383 81.0333 81.0282 81.0231 81.018 81.0129 81.0079 81.0028 80.9978 80.9927 80.9877 80.9826 80.9776 80.9725 80.9675 80.9625 80.9574 80.9524 80.9474 80.9424 80.9374 80.9324 78 | 79 | 80 | 81 | 5000 82 | 5000 83 | 84 | 173.356 173.438 173.519 173.601 173.682 173.764 173.845 173.927 174.008 174.09 174.172 174.253 174.335 174.416 174.498 174.579 174.661 174.742 174.824 174.906 174.987 175.069 175.15 85 | 173.358 173.44 173.521 173.602 173.684 173.765 173.847 173.928 174.01 174.091 174.173 174.254 174.335 174.417 174.498 174.58 174.661 174.743 174.824 174.906 174.987 175.069 175.15 86 | 173.36 173.441 173.523 173.604 173.685 173.767 173.848 173.93 174.011 174.092 174.174 174.255 174.336 174.418 174.499 174.58 174.662 174.743 174.824 174.906 174.987 175.068 175.15 87 | 173.362 173.443 173.525 173.606 173.687 173.768 173.85 173.931 174.012 174.093 174.175 174.256 174.337 174.418 174.5 174.581 174.662 174.743 174.825 174.906 174.987 175.068 175.15 88 | 173.364 173.445 173.526 173.608 173.689 173.77 173.851 173.932 174.013 174.094 174.176 174.257 174.338 174.419 174.5 174.581 174.663 174.744 174.825 174.906 174.987 175.068 175.149 89 | 173.366 173.447 173.528 173.609 173.69 173.771 173.852 173.933 174.014 174.096 174.177 174.258 174.339 174.42 174.501 174.582 174.663 174.744 174.825 174.906 174.987 175.068 175.149 90 | 173.368 173.449 173.53 173.611 173.692 173.773 173.854 173.935 174.016 174.097 174.178 174.259 174.34 174.42 174.501 174.582 174.663 174.744 174.825 174.906 174.987 175.068 175.149 91 | 173.37 173.451 173.532 173.613 173.694 173.774 173.855 173.936 174.017 174.098 174.179 174.259 174.34 174.421 174.502 174.583 174.664 174.745 174.825 174.906 174.987 175.068 175.149 92 | 173.372 173.453 173.534 173.614 173.695 173.776 173.857 173.937 174.018 174.099 174.18 174.26 174.341 174.422 174.503 174.583 174.664 174.745 174.826 174.906 174.987 175.068 175.149 93 | 173.374 173.455 173.535 173.616 173.697 173.777 173.858 173.939 174.019 174.1 174.181 174.261 174.342 174.423 174.503 174.584 174.665 174.745 174.826 174.907 174.987 175.068 175.149 94 | 173.376 173.457 173.537 173.618 173.698 173.779 173.859 173.94 174.021 174.101 174.182 174.262 174.343 174.423 174.504 174.584 174.665 174.746 174.826 174.907 174.987 175.068 175.148 95 | 173.378 173.459 173.539 173.619 173.7 173.78 173.861 173.941 174.022 174.102 174.183 174.263 174.344 174.424 174.505 174.585 174.665 174.746 174.826 174.907 174.987 175.068 175.148 96 | 173.38 173.46 173.541 173.621 173.701 173.782 173.862 173.943 174.023 174.103 174.184 174.264 174.344 174.425 174.505 174.586 174.666 174.746 174.827 174.907 174.987 175.068 175.148 97 | 173.382 173.462 173.543 173.623 173.703 173.783 173.864 173.944 174.024 174.104 174.185 174.265 174.345 174.425 174.506 174.586 174.666 174.747 174.827 174.907 174.987 175.068 175.148 98 | 173.384 173.464 173.544 173.624 173.705 173.785 173.865 173.945 174.025 174.105 174.186 174.266 174.346 174.426 174.506 174.587 174.667 174.747 174.827 174.907 174.987 175.068 175.148 99 | 173.386 173.466 173.546 173.626 173.706 173.786 173.866 173.946 174.027 174.107 174.187 174.267 174.347 174.427 174.507 174.587 174.667 174.747 174.827 174.907 174.987 175.068 175.148 100 | 173.388 173.468 173.548 173.628 173.708 173.788 173.868 173.948 174.028 174.108 174.188 174.268 174.348 174.428 174.508 174.588 174.668 174.748 174.828 174.908 174.988 175.068 175.147 101 | 173.39 173.47 173.55 173.63 173.709 173.789 173.869 173.949 174.029 174.109 174.189 174.269 174.348 174.428 174.508 174.588 174.668 174.748 174.828 174.908 174.988 175.067 175.147 102 | 173.392 173.472 173.551 173.631 173.711 173.791 173.871 173.95 174.03 174.11 174.19 174.27 174.349 174.429 174.509 174.589 174.668 174.748 174.828 174.908 174.988 175.067 175.147 103 | 173.394 173.473 173.553 173.633 173.713 173.792 173.872 173.952 174.031 174.111 174.191 174.27 174.35 174.43 174.51 174.589 174.669 174.749 174.828 174.908 174.988 175.067 175.147 104 | 173.396 173.475 173.555 173.635 173.714 173.794 173.873 173.953 174.033 174.112 174.192 174.271 174.351 174.431 174.51 174.59 174.669 174.749 174.829 174.908 174.988 175.067 175.147 105 | 173.398 173.477 173.557 173.636 173.716 173.795 173.875 173.954 174.034 174.113 174.193 174.272 174.352 174.431 174.511 174.59 174.67 174.749 174.829 174.908 174.988 175.067 175.147 106 | 173.4 173.479 173.558 173.638 173.717 173.797 173.876 173.955 174.035 174.114 174.194 174.273 174.353 174.432 174.511 174.591 174.67 174.75 174.829 174.908 174.988 175.067 175.147 107 | 108 | 109 | 110 | 111 | 81.4801250055295 112 | 174.263139694724 113 | 114 | 115 | 116 | 0 117 | 0 118 | 119 | 0 120 | 0 121 | 122 | 123 | 124 | 0 125 | 0 126 | 127 | 0 128 | 0 129 | 130 | 131 | 132 | 133 | 134 | NaN 135 | NaN 136 | 137 | 138 | NaN 139 | NaN 140 | 141 | 142 | NaN 143 | NaN 144 | 145 | 146 | NaN 147 | NaN 148 | 149 | 150 | NaN 151 | NaN 152 | 153 | 154 | NaN 155 | NaN 156 | 157 | 158 | NaN 159 | NaN 160 | 161 | 162 | NaN 163 | NaN 164 | 165 | 166 | NaN 167 | NaN 168 | 169 | 170 | NaN 171 | NaN 172 | 173 | 174 | NaN 175 | NaN 176 | 177 | 178 | NaN 179 | NaN 180 | 181 | 182 | NaN 183 | NaN 184 | 185 | 186 | 187 | 188 | 189 | 190 | 74.498230 191 | 0.000000 192 | 0.000000 193 | 99.997187 194 | 0.000000 195 | 0.000000 196 | 0.000000 197 | 0.000000 198 | 0.000000 199 | 25.501770 200 | 0.000000 201 | 48.760331 202 | 8.028335 203 | 17.709564 204 | 0.000000 205 | 0.0 206 | 0.0 207 | 0.0 208 | CAMS 209 | 0.060000 210 | 0.000000 211 | AUX_ECMWFT 212 | 350.618205 213 | 214 | 215 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B01.jp2 216 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B01.jp2 217 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B02.jp2 218 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B02.jp2 219 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B03.jp2 220 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B03.jp2 221 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B04.jp2 222 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B04.jp2 223 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B05.jp2 224 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B05.jp2 225 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B06.jp2 226 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B06.jp2 227 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B07.jp2 228 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B07.jp2 229 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B08.jp2 230 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B08.jp2 231 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B8A.jp2 232 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B8A.jp2 233 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B09.jp2 234 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B09.jp2 235 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B10.jp2 236 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B10.jp2 237 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B11.jp2 238 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B11.jp2 239 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_DETFOO_B12.jp2 240 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_QUALIT_B12.jp2 241 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_CLASSI_B00.jp2 242 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_CLDPRB_20m.jp2 243 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_SNWPRB_20m.jp2 244 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_CLDPRB_60m.jp2 245 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/MSK_SNWPRB_60m.jp2 246 | 247 | GRANULE/L2A_T34VEL_A044635_20240108T101404/QI_DATA/T34VEL_20240108T101401_PVI.jp2 248 | 249 | 250 | -------------------------------------------------------------------------------- /tests/data-files/S2A_OPER_MSI_L2A_TL_2APS_20240108T121951_A044635_T34VEL/tileInfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "path" : "tiles/34/V/EL/2024/1/8/0", 3 | "timestamp" : "2024-01-08T10:14:07.485Z", 4 | "utmZone" : 34, 5 | "latitudeBand" : "V", 6 | "gridSquare" : "EL", 7 | "datastrip" : { 8 | "id" : "S2A_OPER_MSI_L2A_DS_2APS_20240108T121951_S20240108T101404_N05.10", 9 | "path" : "products/2024/1/8/S2A_MSIL2A_20240108T101401_N0510_R022_T34VEL_20240108T121951/datastrip/0" 10 | }, 11 | "tileGeometry" : { 12 | "type" : "Polygon", 13 | "crs" : { 14 | "type" : "name", 15 | "properties" : { 16 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32634" 17 | } 18 | }, 19 | "coordinates" : [ [ [ 499980.0, 6600000.0 ], [ 609780.0, 6600000.0 ], [ 609780.0, 6490200.0 ], [ 499980.0, 6490200.0 ], [ 499980.0, 6600000.0 ] ] ] 20 | }, 21 | "tileDataGeometry" : { 22 | "type" : "Polygon", 23 | "crs" : { 24 | "type" : "name", 25 | "properties" : { 26 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32634" 27 | } 28 | }, 29 | "coordinates" : [ [ [ 499981.0, 6522202.646042625 ], [ 499981.0, 6523091.756953125 ], [ 500304.31305821775, 6522970.514555884 ], [ 499981.0, 6522202.646042625 ] ] ] 30 | }, 31 | "tileOrigin" : { 32 | "type" : "Point", 33 | "crs" : { 34 | "type" : "name", 35 | "properties" : { 36 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32634" 37 | } 38 | }, 39 | "coordinates" : [ 499980.0, 6600000.0 ] 40 | }, 41 | "dataCoveragePercentage" : 0.0, 42 | "cloudyPixelPercentage" : 0.0, 43 | "productName" : "S2A_MSIL2A_20240108T101401_N0510_R022_T34VEL_20240108T121951", 44 | "productPath" : "products/2024/1/8/S2A_MSIL2A_20240108T101401_N0510_R022_T34VEL_20240108T121951" 45 | } -------------------------------------------------------------------------------- /tests/data-files/S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBP/tileInfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "path" : "tiles/34/L/BP/2022/4/1/0", 3 | "timestamp" : "2022-04-01T09:03:19.283Z", 4 | "utmZone" : 34, 5 | "latitudeBand" : "L", 6 | "gridSquare" : "BP", 7 | "datastrip" : { 8 | "id" : "S2A_OPER_MSI_L2A_DS_VGS1_20220401T110010_S20220401T090142_N04.00", 9 | "path" : "products/2022/4/1/S2A_MSIL2A_20220401T083601_N0400_R064_T34LBP_20220401T110010/datastrip/0" 10 | }, 11 | "tileGeometry" : { 12 | "type" : "Polygon", 13 | "crs" : { 14 | "type" : "name", 15 | "properties" : { 16 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32734" 17 | } 18 | }, 19 | "coordinates" : [ [ [ 199980.0, 8900020.0 ], [ 309780.0, 8900020.0 ], [ 309780.0, 8790220.0 ], [ 199980.0, 8790220.0 ], [ 199980.0, 8900020.0 ] ] ] 20 | }, 21 | "tileDataGeometry" : { 22 | "type" : "Polygon", 23 | "crs" : { 24 | "type" : "name", 25 | "properties" : { 26 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32734" 27 | } 28 | }, 29 | "coordinates" : [ [ [ 263462.0300079277, 8900019.0 ], [ 309779.0, 8900019.0 ], [ 309779.0, 8888702.791943058 ], [ 290956.23416994535, 8892079.700037826 ], [ 288471.7417770877, 8892443.284290442 ], [ 287899.5927620106, 8892263.232062303 ], [ 262932.099367369, 8896567.533930194 ], [ 263462.0300079277, 8900019.0 ] ] ] 30 | }, 31 | "tileOrigin" : { 32 | "type" : "Point", 33 | "crs" : { 34 | "type" : "name", 35 | "properties" : { 36 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32734" 37 | } 38 | }, 39 | "coordinates" : [ 199980.0, 8900020.0 ] 40 | }, 41 | "dataCoveragePercentage" : 2.85, 42 | "cloudyPixelPercentage" : 0.0, 43 | "productName" : "S2A_MSIL2A_20220401T083601_N0400_R064_T34LBP_20220401T110010", 44 | "productPath" : "products/2022/4/1/S2A_MSIL2A_20220401T083601_N0400_R064_T34LBP_20220401T110010" 45 | } -------------------------------------------------------------------------------- /tests/data-files/S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBQ-no-tileDataGeometry-no-product-metadata/tileInfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "path" : "tiles/34/L/BP/2022/4/1/0", 3 | "timestamp" : "2022-04-01T09:03:19.283Z", 4 | "utmZone" : 34, 5 | "latitudeBand" : "L", 6 | "gridSquare" : "BP", 7 | "datastrip" : { 8 | "id" : "S2A_OPER_MSI_L2A_DS_VGS1_20220401T110010_S20220401T090142_N04.00", 9 | "path" : "products/2022/4/1/S2A_MSIL2A_20220401T083601_N0400_R064_T34LBQ_20220401T110010/datastrip/0" 10 | }, 11 | "tileGeometry" : { 12 | "type" : "Polygon", 13 | "crs" : { 14 | "type" : "name", 15 | "properties" : { 16 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32734" 17 | } 18 | }, 19 | "coordinates" : [ [ [ 199980.0, 8900020.0 ], [ 309780.0, 8900020.0 ], [ 309780.0, 8790220.0 ], [ 199980.0, 8790220.0 ], [ 199980.0, 8900020.0 ] ] ] 20 | }, 21 | "tileOrigin" : { 22 | "type" : "Point", 23 | "crs" : { 24 | "type" : "name", 25 | "properties" : { 26 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32734" 27 | } 28 | }, 29 | "coordinates" : [ 199980.0, 8900020.0 ] 30 | }, 31 | "dataCoveragePercentage" : 2.85, 32 | "cloudyPixelPercentage" : 0.0, 33 | "productName" : "S2A_MSIL2A_20220401T083601_N0400_R064_T34LBQ_20220401T110010", 34 | "productPath" : "products/2022/4/1/S2A_MSIL2A_20220401T083601_N0400_R064_T34LBQ_20220401T110010" 35 | } -------------------------------------------------------------------------------- /tests/data-files/S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBQ-no-tileDataGeometry/tileInfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "path" : "tiles/34/L/BP/2022/4/1/0", 3 | "timestamp" : "2022-04-01T09:03:19.283Z", 4 | "utmZone" : 34, 5 | "latitudeBand" : "L", 6 | "gridSquare" : "BP", 7 | "datastrip" : { 8 | "id" : "S2A_OPER_MSI_L2A_DS_VGS1_20220401T110010_S20220401T090142_N04.00", 9 | "path" : "products/2022/4/1/S2A_MSIL2A_20220401T083601_N0400_R064_T34LBQ_20220401T110010/datastrip/0" 10 | }, 11 | "tileGeometry" : { 12 | "type" : "Polygon", 13 | "crs" : { 14 | "type" : "name", 15 | "properties" : { 16 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32734" 17 | } 18 | }, 19 | "coordinates" : [ [ [ 199980.0, 8900020.0 ], [ 309780.0, 8900020.0 ], [ 309780.0, 8790220.0 ], [ 199980.0, 8790220.0 ], [ 199980.0, 8900020.0 ] ] ] 20 | }, 21 | "tileOrigin" : { 22 | "type" : "Point", 23 | "crs" : { 24 | "type" : "name", 25 | "properties" : { 26 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32734" 27 | } 28 | }, 29 | "coordinates" : [ 199980.0, 8900020.0 ] 30 | }, 31 | "dataCoveragePercentage" : 2.85, 32 | "cloudyPixelPercentage" : 0.0, 33 | "productName" : "S2A_MSIL2A_20220401T083601_N0400_R064_T34LBQ_20220401T110010", 34 | "productPath" : "products/2022/4/1/S2A_MSIL2A_20220401T083601_N0400_R064_T34LBQ_20220401T110010" 35 | } -------------------------------------------------------------------------------- /tests/data-files/S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBQ/tileInfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "path" : "tiles/34/L/BP/2022/4/1/0", 3 | "timestamp" : "2022-04-01T09:03:19.283Z", 4 | "utmZone" : 34, 5 | "latitudeBand" : "L", 6 | "gridSquare" : "BP", 7 | "datastrip" : { 8 | "id" : "S2A_OPER_MSI_L2A_DS_VGS1_20220401T110010_S20220401T090142_N04.00", 9 | "path" : "products/2022/4/1/S2A_MSIL2A_20220401T083601_N0400_R064_T34LBQ_20220401T110010/datastrip/0" 10 | }, 11 | "tileGeometry" : { 12 | "type" : "Polygon", 13 | "crs" : { 14 | "type" : "name", 15 | "properties" : { 16 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32734" 17 | } 18 | }, 19 | "coordinates" : [ [ [ 199980.0, 8900020.0 ], [ 309780.0, 8900020.0 ], [ 309780.0, 8790220.0 ], [ 199980.0, 8790220.0 ], [ 199980.0, 8900020.0 ] ] ] 20 | }, 21 | "tileDataGeometry" : { 22 | "type" : "Polygon", 23 | "crs" : { 24 | "type" : "name", 25 | "properties" : { 26 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32734" 27 | } 28 | }, 29 | "coordinates" : [ [ [ 263462.0300079277, 8900019.0 ], [ 309779.0, 8900019.0 ], [ 309779.0, 8888702.791943058 ], [ 290956.23416994535, 8892079.700037826 ], [ 288471.7417770877, 8892443.284290442 ], [ 287899.5927620106, 8892263.232062303 ], [ 262932.099367369, 8896567.533930194 ], [ 263462.0300079277, 8900019.0 ] ] ] 30 | }, 31 | "tileOrigin" : { 32 | "type" : "Point", 33 | "crs" : { 34 | "type" : "name", 35 | "properties" : { 36 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32734" 37 | } 38 | }, 39 | "coordinates" : [ 199980.0, 8900020.0 ] 40 | }, 41 | "dataCoveragePercentage" : 2.85, 42 | "cloudyPixelPercentage" : 0.0, 43 | "productName" : "S2A_MSIL2A_20220401T083601_N0400_R064_T34LBQ_20220401T110010", 44 | "productPath" : "products/2022/4/1/S2A_MSIL2A_20220401T083601_N0400_R064_T34LBQ_20220401T110010" 45 | } -------------------------------------------------------------------------------- /tests/data-files/S2A_T60CWS_20240109T203651_L2A/tileInfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "path" : "tiles/60/C/WS/2024/1/9/2", 3 | "timestamp" : "2024-01-09T18:47:54.430Z", 4 | "utmZone" : 60, 5 | "latitudeBand" : "C", 6 | "gridSquare" : "WS", 7 | "datastrip" : { 8 | "id" : "S2A_OPER_MSI_L2A_DS_2APS_20240109T203651_S20240109T184746_N05.10", 9 | "path" : "products/2024/1/9/S2A_MSIL2A_20240109T184751_N0510_R041_T60CWS_20240109T203651/datastrip/0" 10 | }, 11 | "tileGeometry" : { 12 | "type" : "Polygon", 13 | "crs" : { 14 | "type" : "name", 15 | "properties" : { 16 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32760" 17 | } 18 | }, 19 | "coordinates" : [ [ [ 499980.0, 1200040.0 ], [ 609780.0, 1200040.0 ], [ 609780.0, 1090240.0 ], [ 499980.0, 1090240.0 ], [ 499980.0, 1200040.0 ] ] ] 20 | }, 21 | "tileDataGeometry" : { 22 | "type" : "Polygon", 23 | "crs" : { 24 | "type" : "name", 25 | "properties" : { 26 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32760" 27 | } 28 | }, 29 | "coordinates" : [ [ [ 591163.3055460646, 1090241.0 ], [ 513316.92800048017, 1090241.0 ], [ 511945.9739765057, 1092545.3695296391 ], [ 512703.1620009291, 1093149.0196924042 ], [ 512130.2270164218, 1094485.8679895883 ], [ 500905.50146942184, 1113393.8281356022 ], [ 500610.6167197615, 1113836.1552600947 ], [ 499981.0, 1113458.3852282614 ], [ 499981.0, 1199938.998304258 ], [ 525207.5770723044, 1199899.3650331306 ], [ 531113.9673957807, 1190234.3626856287 ], [ 531388.3579878905, 1190152.0455079956 ], [ 532124.595374603, 1190504.0557092077 ], [ 544883.6074826597, 1169575.8697626367 ], [ 544664.3246664244, 1168440.7587138892 ], [ 544425.8960637139, 1168267.356094212 ], [ 556507.3207193235, 1148091.3769193436 ], [ 557670.2902307851, 1148241.2324438444 ], [ 570308.1369992306, 1126741.2423532542 ], [ 569450.3581722891, 1126023.1930326498 ], [ 581147.5108543405, 1105583.3231397858 ], [ 581457.9851384623, 1105428.0859977256 ], [ 582322.1955781474, 1105821.9695847214 ], [ 591163.3055460646, 1090241.0 ] ] ] 30 | }, 31 | "tileOrigin" : { 32 | "type" : "Point", 33 | "crs" : { 34 | "type" : "name", 35 | "properties" : { 36 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32760" 37 | } 38 | }, 39 | "coordinates" : [ 499980.0, 1200040.0 ] 40 | }, 41 | "dataCoveragePercentage" : 51.92, 42 | "cloudyPixelPercentage" : 0.0, 43 | "productName" : "S2A_MSIL2A_20240109T184751_N0510_R041_T60CWS_20240109T203651", 44 | "productPath" : "products/2024/1/9/S2A_MSIL2A_20240109T184751_N0510_R041_T60CWS_20240109T203651" 45 | } -------------------------------------------------------------------------------- /tests/data-files/S2B_MSIL2A_20200914T231559_N0500_R087_T01VCG_20230315T224658/tileInfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "path" : "tiles/1/V/CG/2020/9/14/2", 3 | "timestamp" : "2020-09-14T23:19:31.559Z", 4 | "utmZone" : 1, 5 | "latitudeBand" : "V", 6 | "gridSquare" : "CG", 7 | "datastrip" : { 8 | "id" : "S2B_OPER_MSI_L2A_DS_S2RP_20230315T224658_S20200914T231554_N05.00", 9 | "path" : "products/2020/9/14/S2B_MSIL2A_20200914T231559_N0500_R087_T01VCG_20230315T224658/datastrip/0" 10 | }, 11 | "tileGeometry" : { 12 | "type" : "Polygon", 13 | "crs" : { 14 | "type" : "name", 15 | "properties" : { 16 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32601" 17 | } 18 | }, 19 | "coordinates" : [ [ [ 300000.0, 6700020.0 ], [ 409800.0, 6700020.0 ], [ 409800.0, 6590220.0 ], [ 300000.0, 6590220.0 ], [ 300000.0, 6700020.0 ] ] ] 20 | }, 21 | "tileDataGeometry" : { 22 | "type" : "Polygon", 23 | "crs" : { 24 | "type" : "name", 25 | "properties" : { 26 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32601" 27 | } 28 | }, 29 | "coordinates" : [ [ [ 343066.70321030833, 6700019.0 ], [ 409799.0, 6700019.0 ], [ 409799.0, 6648516.735831021 ], [ 382830.7606497702, 6657456.207763805 ], [ 336925.31344020634, 6671597.885836635 ], [ 335504.76069826295, 6671896.62868802 ], [ 334822.07879198395, 6672200.73244617 ], [ 334071.03939401696, 6672210.100921236 ], [ 333972.78124229837, 6672499.879196874 ], [ 333399.4225043839, 6672508.805111303 ], [ 343066.70321030833, 6700019.0 ] ] ] 30 | }, 31 | "tileOrigin" : { 32 | "type" : "Point", 33 | "crs" : { 34 | "type" : "name", 35 | "properties" : { 36 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32601" 37 | } 38 | }, 39 | "coordinates" : [ 300000.0, 6700020.0 ] 40 | }, 41 | "dataCoveragePercentage" : 23.75, 42 | "cloudyPixelPercentage" : 0.0, 43 | "productName" : "S2B_MSIL2A_20200914T231559_N0500_R087_T01VCG_20230315T224658", 44 | "productPath" : "products/2020/9/14/S2B_MSIL2A_20200914T231559_N0500_R087_T01VCG_20230315T224658" 45 | } -------------------------------------------------------------------------------- /tests/data-files/S2B_OPER_MSI_L2A_DS_VGS1_20201101T095401_S20201101T074429-no-data/tileInfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "path" : "tiles/37/M/GS/2020/11/1/0", 3 | "timestamp" : "2020-11-01T07:50:38.013Z", 4 | "utmZone" : 37, 5 | "latitudeBand" : "M", 6 | "gridSquare" : "GS", 7 | "datastrip" : { 8 | "id" : "S2B_OPER_MSI_L2A_DS_VGS1_20201101T095401_S20201101T074429_N02.14", 9 | "path" : "products/2020/11/1/S2B_MSIL2A_20201101T073049_N0214_R049_T37MGS_20201101T095401/datastrip/0" 10 | }, 11 | "tileGeometry" : { 12 | "type" : "Polygon", 13 | "crs" : { 14 | "type" : "name", 15 | "properties" : { 16 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32737" 17 | } 18 | }, 19 | "coordinates" : [ [ [ 699960.0, 9700000.0 ], [ 809760.0, 9700000.0 ], [ 809760.0, 9590200.0 ], [ 699960.0, 9590200.0 ], [ 699960.0, 9700000.0 ] ] ] 20 | }, 21 | "tileDataGeometry" : { 22 | "type" : "Polygon", 23 | "crs" : { 24 | "type" : "name", 25 | "properties" : { 26 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32737" 27 | } 28 | }, 29 | "coordinates" : [ [ ] ] 30 | }, 31 | "tileOrigin" : { 32 | "type" : "Point", 33 | "crs" : { 34 | "type" : "name", 35 | "properties" : { 36 | "name" : "urn:ogc:def:crs:EPSG:8.8.1:32737" 37 | } 38 | }, 39 | "coordinates" : [ 699960.0, 9700000.0 ] 40 | }, 41 | "dataCoveragePercentage" : 0.0, 42 | "cloudyPixelPercentage" : 0.0, 43 | "productName" : "S2B_MSIL2A_20201101T073049_N0214_R049_T37MGS_20201101T095401", 44 | "productPath" : "products/2020/11/1/S2B_MSIL2A_20201101T073049_N0214_R049_T37MGS_20201101T095401" 45 | } -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: E501 2 | 3 | import os 4 | import re 5 | from collections import defaultdict 6 | from itertools import chain 7 | from pathlib import Path 8 | from typing import Any, Dict, Final, List 9 | 10 | import pystac 11 | import pytest 12 | from click import Group 13 | from click.testing import CliRunner 14 | from pystac.extensions.eo import EOExtension 15 | from pystac.extensions.grid import GridExtension 16 | from pystac.extensions.projection import ProjectionExtension 17 | from pystac.extensions.view import ViewExtension 18 | from pystac.utils import is_absolute_href 19 | from shapely.geometry import box, mapping, shape 20 | 21 | from stactools.core.projection import reproject_shape 22 | from stactools.sentinel2.commands import create_sentinel2_command 23 | from stactools.sentinel2.constants import ( 24 | COORD_ROUNDING, 25 | SENTINEL_BANDS, 26 | ) 27 | from stactools.sentinel2.constants import SENTINEL2_PROPERTY_PREFIX as s2_prefix 28 | from stactools.sentinel2.mgrs import MgrsExtension 29 | from stactools.sentinel2.utils import extract_gsd 30 | from tests import test_data 31 | 32 | BANDS_TO_RESOLUTIONS: Final[Dict[str, List[int]]] = { 33 | # asset coastal is 60, coastal_20m is 20, as 20m wasn't added until 2021/22 34 | "B01": [60, 20], 35 | "B02": [10, 20, 60], 36 | "B03": [10, 20, 60], 37 | "B04": [10, 20, 60], 38 | "B05": [20, 60], 39 | "B06": [20, 60], 40 | "B07": [20, 60], 41 | "B08": [10, 20, 60], 42 | "B8A": [20, 60], 43 | "B09": [60], 44 | "B10": [60], 45 | "B11": [20, 60], 46 | "B12": [20, 60], 47 | } 48 | ID_TO_FILE_NAME = { 49 | "S2A_T46RER_20210908T043714_L1C": "S2A_MSIL1C_20210908T042701_N0301_R133_T46RER_20210908T070248.SAFE", 50 | "S2A_T07HFE_20190212T192646_L2A": "S2A_MSIL2A_20190212T192651_N0212_R013_T07HFE_20201007T160857.SAFE", 51 | "S2B_T01CCV_20191228T210521_L2A": "S2B_MSIL2A_20191228T210519_N0212_R071_T01CCV_20201003T104658.SAFE", 52 | "S2B_T22HBD_20210122T133224_L2A": "esa_S2B_MSIL2A_20210122T133229_N0214_R081_T22HBD_20210122T155500.SAFE", 53 | "S2B_T33XWJ_20220413T150756_L2A": "S2B_MSIL2A_20220413T150759_N0400_R025_T33XWJ_20220414T082126.SAFE", 54 | "S2A_OPER_MSI_L2A_TL_SGS__20181231T210250_A018414_T10SDG": "S2A_OPER_MSI_L2A_TL_SGS__20181231T210250_A018414_T10SDG", 55 | "S2A_OPER_MSI_L1C_TL_SGS__20181231T203637_A018414_T10SDG": "S2A_OPER_MSI_L1C_TL_SGS__20181231T203637_A018414_T10SDG", 56 | "S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBP": "S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBP", 57 | "S2A_T34LBQ_20220401T090142_L2A": "S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBQ", 58 | # antimeridian-crossing scene 59 | "S2A_T01LAC_20200717T221944_L1C": "S2A_MSIL1C_20200717T221941_R029_T01LAC_20200717T234135.SAFE", 60 | # antimeridian-crossing scene with positive lon centroid 61 | "S2A_T01WCP_20230625T234624_L2A": "S2A_MSIL2A_20230625T234621_N0509_R073_T01WCP_20230626T022157.SAFE", 62 | # antimeridian-crossing scene with negative lon centroid 63 | "S2A_T01WCS_20230625T234624_L2A": "S2A_MSIL2A_20230625T234621_N0509_R073_T01WCS_20230626T022157.SAFE", 64 | # both sun_azimuth and sun_zenith can be NaN, so don't set 65 | # "S2A_T01WCP_20230625T234624_L2A": "S2A_MSIL2A_20230625T234621_N0509_R073_T01WCP_20230626T022158.SAFE", 66 | # viewing angles are all NaN, so don't set 67 | "S2A_OPER_MSI_L2A_TL_2APS_20240108T121951_A044635_T34VEL": "S2A_OPER_MSI_L2A_TL_2APS_20240108T121951_A044635_T34VEL", 68 | } 69 | 70 | 71 | def proj_bbox_area_difference(item): 72 | projection = ProjectionExtension.ext(item) 73 | visual_asset = item.assets.get("visual_10m") or item.assets.get("visual") 74 | asset_projection = ProjectionExtension.ext(visual_asset) 75 | pb = mapping(box(*asset_projection.bbox)) 76 | proj_geom = reproject_shape(f"epsg:{projection.epsg}", "epsg:4326", pb) 77 | 78 | item_geom = shape(item.geometry) 79 | 80 | difference_area = item_geom.difference(proj_geom).area 81 | raster_area = proj_geom.area 82 | 83 | # We expect the footprint to be in the raster 84 | # bounds, so any difference should be relatively very low 85 | # and due to reprojection. 86 | return difference_area / raster_area 87 | 88 | 89 | @pytest.mark.parametrize("item_id,file_name", ID_TO_FILE_NAME.items()) 90 | def test_create_item(tmp_path: Path, item_id: str, file_name: str): 91 | granule_href = test_data.get_path(f"data-files/{file_name}") 92 | runner = CliRunner() 93 | runner.invoke( 94 | create_sentinel2_command(Group()), 95 | ["create-item", granule_href, str(tmp_path)], 96 | ) 97 | jsons = [p for p in os.listdir(tmp_path) if p.endswith(".json")] 98 | assert len(jsons) == 1 99 | file_name = jsons[0] 100 | item = pystac.Item.from_file(str(tmp_path / file_name)) 101 | item.validate() 102 | assert item.id == item_id 103 | 104 | def mk_comparable(i: pystac.Item) -> Dict[str, Any]: 105 | i.common_metadata.created = None 106 | i.make_asset_hrefs_absolute() 107 | d = i.to_dict(include_self_link=False) 108 | 109 | if d["geometry"]["type"] == "Polygon": 110 | if len(d["geometry"]["coordinates"]) > 1: 111 | for i in range(0, len(d["geometry"]["coordinates"][0])): 112 | for c in d["geometry"]["coordinates"][0][i]: 113 | c[0] = round(c[0], COORD_ROUNDING) 114 | c[1] = round(c[1], COORD_ROUNDING) 115 | else: 116 | for c in d["geometry"]["coordinates"][0]: 117 | c[0] = round(c[0], COORD_ROUNDING) 118 | c[1] = round(c[1], COORD_ROUNDING) 119 | 120 | for i, v in enumerate(bbox := d["bbox"]): 121 | bbox[i] = round(v, 5) 122 | 123 | return d 124 | 125 | assert item.common_metadata.created is not None 126 | 127 | expected = Path(f"{granule_href}/expected_output.json") 128 | if not expected.exists(): 129 | item.set_self_href(str(expected)) 130 | item.make_asset_hrefs_relative() 131 | item.save_object(include_self_link=False, dest_href=expected) 132 | 133 | assert mk_comparable(item) == mk_comparable(pystac.Item.from_file(expected)), ( 134 | f"Doesn't match expectation: {str(expected)}" 135 | ) 136 | 137 | bands_seen = set() 138 | bands_to_assets = defaultdict(list) 139 | 140 | for key, asset in item.assets.items(): 141 | # Ensure that there's no relative path parts 142 | # in the asset HREFs 143 | assert "/./" not in asset.href 144 | assert is_absolute_href(asset.href) 145 | asset_eo = EOExtension.ext(asset) 146 | bands = asset_eo.bands 147 | if bands is not None: 148 | bands_seen |= set(b.name for b in bands) 149 | if key.split("_")[0] in SENTINEL_BANDS: 150 | for b in bands: 151 | bands_to_assets[b.name].append((key, asset)) 152 | 153 | used_bands = dict(SENTINEL_BANDS) 154 | # if item.properties[f"{s2_prefix}:product_type"] == "S2MSI1C": 155 | # used_bands = SENTINEL_BANDS 156 | if item.properties[f"{s2_prefix}:product_type"] == "S2MSI2A": 157 | used_bands.pop("cirrus") 158 | for b in chain(used_bands.keys(), ["visual", "aot", "wvp", "scl"]): 159 | assert b in item.assets 160 | 161 | assert bands_seen == set([b.name for k, b in used_bands.items()]) 162 | 163 | # Check that multiple resolutions exist for assets that 164 | # have them, and that they are named such that the highest 165 | # resolution asset is the band name, and others are 166 | # appended with the resolution. 167 | 168 | resolutions_seen = defaultdict(list) 169 | 170 | # Level 1C does not have the same layout as Level 2A. So the 171 | # whole resolution 172 | if item.properties[f"{s2_prefix}:product_type"] == "S2MSI1C": 173 | for band_name, assets in bands_to_assets.items(): 174 | for asset_key, asset in assets: 175 | resolutions_seen[band_name].append(asset.extra_fields["gsd"]) 176 | 177 | # Level 1C only has the highest resolution version of each band 178 | used_resolutions = { 179 | band: [resolutions[0]] for band, resolutions in BANDS_TO_RESOLUTIONS.items() 180 | } 181 | elif item.properties[f"{s2_prefix}:product_type"] == "S2MSI2A": 182 | for band_name, assets in bands_to_assets.items(): 183 | for asset_key, asset in assets: 184 | resolutions = BANDS_TO_RESOLUTIONS[band_name] 185 | 186 | asset_split = asset_key.split("_") 187 | assert len(asset_split) <= 2 188 | 189 | href_band = re.search(r"[_/](B\d[A\d])", asset.href).group(1) 190 | asset_res = extract_gsd(asset.href) 191 | assert href_band == band_name 192 | if len(asset_split) == 1: 193 | assert asset_res == resolutions[0] 194 | assert "gsd" in asset.extra_fields 195 | resolutions_seen[band_name].append(asset_res) 196 | else: 197 | assert asset_res != resolutions[0] 198 | assert asset_res in resolutions 199 | assert "gsd" not in asset.extra_fields 200 | resolutions_seen[band_name].append(asset_res) 201 | 202 | # Level 2A does not have Band 10 203 | used_resolutions = dict(BANDS_TO_RESOLUTIONS) 204 | used_resolutions.pop("B10") 205 | 206 | assert set(resolutions_seen.keys()) == set(used_resolutions.keys()) 207 | 208 | # self.assertLess(proj_bbox_area_difference(item), 0.005) 209 | 210 | mgrs = MgrsExtension.ext(item) 211 | assert f"_T{mgrs.utm_zone:02d}{mgrs.latitude_band}{mgrs.grid_square}" in item.id 212 | assert mgrs.latitude_band 213 | assert mgrs.utm_zone 214 | assert mgrs.grid_square 215 | 216 | grid = GridExtension.ext(item) 217 | assert grid.code 218 | grid_id = grid.code.split("-")[1] 219 | if len(grid_id) == 4: 220 | grid_id = f"0{grid_id}" # add zero pad 221 | assert f"_T{grid_id}" in item.id 222 | 223 | try: 224 | view = ViewExtension.ext(item) 225 | assert view.sun_azimuth 226 | assert view.sun_elevation 227 | except pystac.errors.ExtensionNotImplemented as e: 228 | # this item is the example that doesn't have the View Extension 229 | # applied because the values are NaN 230 | if item_id != "S2A_T01WCP_20230625T234624_L2A": 231 | raise e 232 | 233 | proj = ProjectionExtension.ext(item) 234 | assert proj.centroid 235 | assert proj.centroid["lat"] 236 | assert proj.centroid["lon"] 237 | -------------------------------------------------------------------------------- /tests/test_metadata.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from shapely.geometry import box, mapping, shape 4 | 5 | from stactools.core.projection import reproject_shape 6 | from stactools.sentinel2.constants import SENTINEL2_PROPERTY_PREFIX as s2_prefix 7 | from stactools.sentinel2.granule_metadata import GranuleMetadata 8 | from stactools.sentinel2.product_metadata import ProductMetadata 9 | from stactools.sentinel2.safe_manifest import SafeManifest 10 | from tests import test_data 11 | 12 | 13 | class Sentinel2MetadataTest(unittest.TestCase): 14 | def test_parses_product_metadata_properties(self): 15 | manifest_path = test_data.get_path( 16 | "data-files/S2A_MSIL2A_20190212T192651_N0212_R013_T07HFE_20201007T160857.SAFE" 17 | ) 18 | 19 | manifest = SafeManifest(manifest_path) 20 | 21 | product_metadata = ProductMetadata(manifest.product_metadata_href) 22 | granule_metadata = GranuleMetadata(manifest.granule_metadata_href) 23 | 24 | s2_props = product_metadata.metadata_dict 25 | s2_props.update(granule_metadata.metadata_dict) 26 | 27 | # fmt: off 28 | expected = { 29 | # From product metadata 30 | f"{s2_prefix}:product_uri": 31 | "S2A_MSIL2A_20190212T192651_N0212_R013_T07HFE_20201007T160857.SAFE", 32 | f"{s2_prefix}:generation_time": "2020-10-07T16:08:57.135Z", 33 | f"{s2_prefix}:processing_baseline": "02.12", 34 | f"{s2_prefix}:product_type": "S2MSI2A", 35 | f"{s2_prefix}:datatake_id": "GS2A_20190212T192651_019029_N02.12", 36 | f"{s2_prefix}:datatake_type": "INS-NOBS", 37 | f"{s2_prefix}:datastrip_id": 38 | "S2A_OPER_MSI_L2A_DS_ESRI_20201007T160858_S20190212T192646_N02.12", 39 | f"{s2_prefix}:tile_id": 40 | "S2A_OPER_MSI_L2A_TL_ESRI_20201007T160858_A019029_T07HFE_N02.12", 41 | f"{s2_prefix}:reflectance_conversion_factor": 1.02763689829235, 42 | # From granule metadata 43 | f"{s2_prefix}:degraded_msi_data_percentage": 0.0, 44 | f"{s2_prefix}:nodata_pixel_percentage": 96.769553, 45 | f"{s2_prefix}:saturated_defective_pixel_percentage": 0.0, 46 | f"{s2_prefix}:dark_features_percentage": 0.0, 47 | f"{s2_prefix}:cloud_shadow_percentage": 0.0, 48 | f"{s2_prefix}:vegetation_percentage": 0.000308, 49 | f"{s2_prefix}:not_vegetated_percentage": 0.069531, 50 | f"{s2_prefix}:water_percentage": 48.349833, 51 | f"{s2_prefix}:unclassified_percentage": 0.0, 52 | f"{s2_prefix}:medium_proba_clouds_percentage": 14.61311, 53 | f"{s2_prefix}:high_proba_clouds_percentage": 24.183494, 54 | f"{s2_prefix}:thin_cirrus_percentage": 12.783723, 55 | f"{s2_prefix}:snow_ice_percentage": 0.0, 56 | } 57 | # fmt: on 58 | 59 | for k, v in expected.items(): 60 | self.assertIn(k, s2_props) 61 | self.assertEqual(s2_props[k], v) 62 | 63 | self.assertEqual(granule_metadata.cloudiness_percentage, 51.580326) 64 | self.assertEqual(granule_metadata.snow_ice_percentage, 0.0) 65 | self.assertEqual( 66 | granule_metadata.processing_baseline, 67 | s2_props[f"{s2_prefix}:processing_baseline"], 68 | ) 69 | 70 | def test_footprint_containing_geom_with_z_dimension(self): 71 | product_md_path = test_data.get_path( 72 | "data-files/S2A_MSIL2A_20150826T185436_N0212_R070" 73 | "_T11SLT_20210412T023147/MTD_MSIL2A.xml" 74 | ) 75 | granule_md_path = test_data.get_path( 76 | "data-files/S2A_MSIL2A_20150826T185436_N0212_R070" 77 | "_T11SLT_20210412T023147/MTD_TL.xml" 78 | ) 79 | product_metadata = ProductMetadata(product_md_path) 80 | granule_metadata = GranuleMetadata(granule_md_path) 81 | 82 | footprint = shape(product_metadata.geometry) 83 | 84 | proj_bbox = granule_metadata.proj_bbox 85 | epsg = granule_metadata.epsg 86 | 87 | proj_box = box(*proj_bbox) 88 | ll_proj_box = shape( 89 | reproject_shape(f"epsg:{epsg}", "epsg:4326", mapping(proj_box)) 90 | ) 91 | 92 | # Test that the bboxes roughly match by ensuring the difference 93 | # is less than 5% of total area of the reprojected proj bbox. 94 | self.assertTrue( 95 | footprint.envelope.difference(ll_proj_box).area < (ll_proj_box.area * 0.05) 96 | ) 97 | 98 | def test_footprint_containing_geom_with_0_parses(self): 99 | product_md_path = test_data.get_path( 100 | "data-files/S2A_MSIL2A_20180721T053721" 101 | "_N0212_R062_T43MDV_20201011T181419.SAFE/MTD_MSIL2a.xml" 102 | ) 103 | product_metadata = ProductMetadata(product_md_path) 104 | 105 | footprint = shape(product_metadata.geometry) 106 | 107 | self.assertTrue(footprint.is_valid) 108 | -------------------------------------------------------------------------------- /tests/test_stac.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import shapely.geometry 3 | 4 | from stactools.sentinel2 import stac 5 | 6 | from . import test_data 7 | 8 | 9 | def test_product_metadata_asset() -> None: 10 | file_name = "S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBQ" 11 | path = test_data.get_path(f"data-files/{file_name}") 12 | item = stac.create_item(path) 13 | assert "product_metadata" in item.assets 14 | 15 | 16 | # this scene has one vertex that just crosses the antimeridian 17 | # but is snapped to the antimeridian line 18 | def test_one_vertex_just_crossing() -> None: 19 | file_name = "S2B_MSIL2A_20200914T231559_N0500_R087_T01VCG_20230315T224658" # noqa 20 | path = test_data.get_path(f"data-files/{file_name}") 21 | stac.create_item(path) 22 | 23 | 24 | # this scene previously produced a correct geometry when running on arm64, but incorrect 25 | # large globe-spanning geometry on amd64. This checks for regression. 26 | def test_polar_antimeridian_crossing() -> None: 27 | file_name = "S2A_T60CWS_20240109T203651_L2A" # noqa 28 | path = test_data.get_path(f"data-files/{file_name}") 29 | stac.create_item(path) 30 | 31 | 32 | # this scene previously created a geometry that's globe-spanning 33 | # checks for regression 34 | def test_antimeridian_crossing() -> None: 35 | file_name = "S2A_OPER_MSI_L2A_DS_2APS_20230105T201055_S20230105T163809" # noqa 36 | path = test_data.get_path(f"data-files/{file_name}") 37 | stac.create_item(path) 38 | 39 | 40 | def test_antimeridian() -> None: 41 | path = test_data.get_path( 42 | "data-files/S2A_MSIL2A_20230821T221941_N0509_R029_T01KAB_20230822T021825.SAFE" 43 | ) 44 | item = stac.create_item(path) 45 | expected = { 46 | "type": "MultiPolygon", 47 | "coordinates": [ 48 | [ 49 | [ 50 | [180.0, -16.259071], 51 | [180.0, -16.259071], 52 | [179.258625, -16.247763], 53 | [179.23921, -17.238192], 54 | [180.0, -17.250482], 55 | [180.0, -17.250482], 56 | [180.0, -16.259071], 57 | ] 58 | ], 59 | [ 60 | [ 61 | [-180.0, -17.250482], 62 | [-179.72957, -17.25485], 63 | [-179.71545, -16.263411], 64 | [-180.0, -16.259071], 65 | [-180.0, -17.250482], 66 | ] 67 | ], 68 | ], 69 | } 70 | assert ( 71 | shapely.geometry.shape(expected) 72 | .normalize() 73 | .equals_exact(shapely.geometry.shape(item.geometry).normalize(), tolerance=5) 74 | ) 75 | 76 | 77 | def test_make_geometry_collection_filter(): 78 | input_geometry = { 79 | "type": "Polygon", 80 | "coordinates": [ 81 | [ 82 | [-70, 6], 83 | [-74, 6], 84 | [-74, 6.5], # 85 | [-72, 6.5], # will result in a linestring 86 | [-74, 6.5], # 87 | [-74, 7], 88 | [-70, 7], 89 | [-70, 6], 90 | ] 91 | ], 92 | } 93 | 94 | expected = { 95 | "type": "Polygon", 96 | "coordinates": [[[-74, 7], [-70, 7], [-70, 6], [-74, 6], [-74, 6.5], [-74, 7]]], 97 | } 98 | valid_geometry = stac.make_valid_geometry(input_geometry) 99 | 100 | assert ( 101 | shapely.geometry.shape(expected) 102 | .normalize() 103 | .equals_exact(valid_geometry.normalize(), tolerance=5) 104 | ) 105 | 106 | 107 | def test_fallback_geometry(): 108 | file_name = ( 109 | "S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBQ-no-tileDataGeometry" # noqa 110 | ) 111 | path = test_data.get_path(f"data-files/{file_name}") 112 | stac.create_item(path) 113 | 114 | 115 | @pytest.mark.parametrize( 116 | ("file_name", "allow_fallback_geometry"), 117 | [ 118 | [ 119 | "S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBQ-no-tileDataGeometry", 120 | False, 121 | ], # Raise if no tileDataGeometry and not allowed to fallback 122 | [ 123 | "S2B_OPER_MSI_L2A_DS_VGS1_20201101T095401_S20201101T074429-no-data", 124 | False, 125 | ], # Raise if no coords in tileDataGeometry and not allowed to fallback 126 | [ 127 | "S2A_OPER_MSI_L2A_TL_VGS1_20220401T110010_A035382_T34LBQ-no-tileDataGeometry-no-product-metadata", 128 | True, 129 | ], # Raise if no tileDataGeometry and no product metadata 130 | ], 131 | ) 132 | def test_fallback_geometry_raises(file_name, allow_fallback_geometry): 133 | path = test_data.get_path(f"data-files/{file_name}") 134 | with pytest.raises(ValueError) as e: 135 | stac.create_item(path, allow_fallback_geometry=allow_fallback_geometry) 136 | assert "Metadata does not contain geometry" in str(e) 137 | --------------------------------------------------------------------------------