├── tests ├── data │ └── warning_list.txt ├── __init__.py ├── test_bitmask │ ├── test_bitgroup_get_masks.png │ ├── test_bitmask_get_masks.png │ ├── test_bitgroup_get_mask_by_position.png │ ├── test_bitgroup_get_mask_by_bit_value.png │ ├── serialized_test_bitgroup_get_mask_by_position.yml │ ├── serialized_test_bitgroup_decode_to_columns.yml │ ├── test_bitgroup_decode_to_columns.yml │ └── test_bitmask_decode_to_columns.yml ├── test_handler.py ├── conftest.py ├── check_warnings.py ├── test_helpers.py └── test_bitmask.py ├── docs ├── usage.rst ├── contribute.rst ├── _static │ ├── custom.css │ └── custom-icon.js ├── _template │ └── pypackage-credit.html ├── index.rst └── conf.py ├── eebit ├── py.typed ├── __init__.py ├── bithandler.py ├── helpers.py └── bitmask.py ├── codecov.yml ├── .readthedocs.yaml ├── CITATION.cff ├── .devcontainer └── devcontainer.json ├── .copier-answers.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── PULL_REQUEST_TEMPLATE │ │ └── pr_template.md │ └── feature_request.md └── workflows │ ├── release.yaml │ ├── pypackage_check.yaml │ └── unit.yaml ├── AUTHORS.rst ├── LICENSE ├── .pre-commit-config.yaml ├── README.rst ├── .gitignore ├── pyproject.toml ├── noxfile.py ├── CONTRIBUTING.rst └── CODE_OF_CONDUCT.rst /tests/data/warning_list.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """make test folder a package for coverage.""" 2 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | **eebit** usage documentation. 5 | -------------------------------------------------------------------------------- /eebit/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file for PEP 561. The mypy package uses inline types. -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | # disable the treemap comment and report in PRs 2 | comment: false 3 | -------------------------------------------------------------------------------- /docs/contribute.rst: -------------------------------------------------------------------------------- 1 | Contribute 2 | ========== 3 | 4 | .. include:: ../CONTRIBUTING.rst 5 | :start-line: 3 6 | -------------------------------------------------------------------------------- /tests/test_bitmask/test_bitgroup_get_masks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fitoprincipe/eebit/main/tests/test_bitmask/test_bitgroup_get_masks.png -------------------------------------------------------------------------------- /tests/test_bitmask/test_bitmask_get_masks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fitoprincipe/eebit/main/tests/test_bitmask/test_bitmask_get_masks.png -------------------------------------------------------------------------------- /tests/test_bitmask/test_bitgroup_get_mask_by_position.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fitoprincipe/eebit/main/tests/test_bitmask/test_bitgroup_get_mask_by_position.png -------------------------------------------------------------------------------- /tests/test_bitmask/test_bitgroup_get_mask_by_bit_value.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fitoprincipe/eebit/main/tests/test_bitmask/test_bitgroup_get_mask_by_bit_value.png -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | /* add dollar sign in console code-block */ 2 | div.highlight-console pre span.go::before { 3 | content: "$"; 4 | margin-right: 10px; 5 | margin-left: 5px; 6 | } 7 | -------------------------------------------------------------------------------- /docs/_template/pypackage-credit.html: -------------------------------------------------------------------------------- 1 |

2 | From 3 | @12rambau/pypackage 4 | 0.1.16 Copier project. 5 |

6 | -------------------------------------------------------------------------------- /eebit/__init__.py: -------------------------------------------------------------------------------- 1 | """The init file of the package.""" 2 | 3 | __version__ = "0.0.0" 4 | __author__ = "Rodrigo Principe" 5 | __email__ = "fitoprincipe82@gmail.com" 6 | 7 | 8 | from .bitmask import BitMask 9 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | 3 | version: 2 4 | 5 | build: 6 | os: ubuntu-22.04 7 | tools: 8 | python: "3.10" 9 | 10 | sphinx: 11 | configuration: docs/conf.py 12 | 13 | python: 14 | install: 15 | - method: pip 16 | path: . 17 | extra_requirements: 18 | - doc 19 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: "1.2.0" 2 | message: "If you use this software, please cite it as below." 3 | authors: 4 | - family-names: "Principe" 5 | given-names: "Rodrigo" 6 | orcid: "https://orcid.org/0000-0000-0000-0000" 7 | title: "eebit" 8 | version: "0.0.0" 9 | doi: "" 10 | date-released: "2025-06-28" 11 | url: "https://github.com/fitoprincipe/eebit" 12 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3", 3 | "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", 4 | "features": { 5 | "ghcr.io/devcontainers-contrib/features/nox:2": {}, 6 | "ghcr.io/devcontainers-contrib/features/pre-commit:2": {} 7 | }, 8 | "postCreateCommand": "python -m pip install commitizen uv && pre-commit install" 9 | } 10 | -------------------------------------------------------------------------------- /tests/test_handler.py: -------------------------------------------------------------------------------- 1 | """Test for main class BitHandler.""" 2 | 3 | from eebit.bithandler import BitHandler 4 | 5 | 6 | class TestBitHandler: 7 | def test_all_bits(self, l89_qa_bits, data_regression): 8 | bh = BitHandler(l89_qa_bits) 9 | data_regression.check(bh) 10 | 11 | def test_from_asset(self): 12 | handler = BitHandler.from_asset("LANDSAT/LC09/C02/T1_L2", "QA_PIXEL") 13 | print() 14 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier 2 | _commit: 0.1.16 3 | _src_path: gh:12rambau/pypackage 4 | author_email: fitoprincipe82@gmail.com 5 | author_first_name: Rodrigo 6 | author_last_name: Principe 7 | author_orcid: 0000-0000-0000-0000 8 | creation_year: "2025" 9 | github_repo_name: eebit 10 | github_user: fitoprincipe 11 | project_name: eebit 12 | project_slug: eebit 13 | short_description: read / decode / write bit information encoded in GEE images 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE/pr_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull request template 3 | about: Create a pull request 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | ## reference the related issue 10 | 11 | PR should answer problem stated in the issue tracker. please open one before starting a PR 12 | 13 | ## description of the changes 14 | 15 | Describe the changes you propose 16 | 17 | ## mention 18 | 19 | @mentions of the person or team responsible for reviewing proposed changes 20 | 21 | ## comments 22 | 23 | any other comments we should pay attention to 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | :html_theme.sidebar_secondary.remove: 2 | 3 | 4 | eebit 5 | ===== 6 | 7 | .. toctree:: 8 | :hidden: 9 | 10 | usage 11 | contribute 12 | 13 | Documentation contents 14 | ---------------------- 15 | 16 | The documentation contains 3 main sections: 17 | 18 | .. grid:: 1 2 3 3 19 | 20 | .. grid-item:: 21 | 22 | .. card:: Usage 23 | :link: usage.html 24 | 25 | Usage and installation 26 | 27 | .. grid-item:: 28 | 29 | .. card:: Contribute 30 | :link: contribute.html 31 | 32 | Help us improve the lib. 33 | 34 | .. grid-item:: 35 | 36 | .. card:: API 37 | :link: autoapi/index.html 38 | 39 | Discover the lib API. 40 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Thanks goes to these wonderful people (`emoji key `_): 2 | 3 | .. raw:: html 4 | 5 | 6 | 7 | 8 | 15 | 16 | 17 |
9 | 10 | fitoprincipe
11 | Rodrigo Principe 12 |
13 | 💻 14 |
18 | 19 | This project follows the `all-contributors `_ specification. 20 | 21 | Contributions of any kind are welcome! 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | env: 8 | PIP_ROOT_USER_ACTION: ignore 9 | 10 | jobs: 11 | tests: 12 | uses: ./.github/workflows/unit.yaml 13 | 14 | deploy: 15 | needs: [tests] 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.11" 22 | - name: Install dependencies 23 | run: pip install twine build nox[uv] 24 | - name: update citation date 25 | run: nox -s release-date 26 | - name: Build and publish 27 | env: 28 | TWINE_USERNAME: __token__ 29 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 30 | run: python -m build && twine upload dist/* 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Rodrigo Principe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest session configuration.""" 2 | 3 | import ee 4 | import pytest 5 | import pytest_gee 6 | 7 | 8 | def pytest_configure() -> None: 9 | """Initialize earth engine according to the environment.""" 10 | pytest_gee.init_ee_from_service_account() 11 | 12 | 13 | @pytest.fixture() 14 | def l89_qa_bits(): 15 | return { 16 | "3-3-Cloud": "Cloud", 17 | "4-4-Cloud Shadow": "Cloud Shadow", 18 | "5-5-Snow": "Snow", 19 | "7-7-Water": "Water", 20 | "8-9-Cloud Confidence": { 21 | "1": "Clouds Low Prob", 22 | "2": "Clouds Medium Prob", 23 | "3": "Clouds High Prob", 24 | }, 25 | "10-11-Shadow Confidence": { 26 | "1": "Shadow Low Prob", 27 | "2": "Shadow Medium Prob", 28 | "3": "Shadow High Prob", 29 | }, 30 | } 31 | 32 | 33 | @pytest.fixture() 34 | def cloudy_l8_patagonia(): 35 | """A cloudy L8 image in Patagonia.""" 36 | return ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_231090_20240107") 37 | 38 | 39 | @pytest.fixture() 40 | def aoi_patagonia(): 41 | """An AOI in Patagonia.""" 42 | return ee.Geometry.Polygon( 43 | [[[-71.72, -42.96], [-71.72, -43.18], [-71.39, -43.18], [-71.39, -42.96]]] 44 | ) 45 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: [pre-commit, commit-msg] 2 | 3 | repos: 4 | - repo: "https://github.com/commitizen-tools/commitizen" 5 | rev: "v2.18.0" 6 | hooks: 7 | - id: commitizen 8 | stages: [commit-msg] 9 | 10 | - repo: "https://github.com/kynan/nbstripout" 11 | rev: "0.5.0" 12 | hooks: 13 | - id: nbstripout 14 | stages: [pre-commit] 15 | 16 | - repo: "https://github.com/pycontribs/mirrors-prettier" 17 | rev: "v3.4.2" 18 | hooks: 19 | - id: prettier 20 | stages: [pre-commit] 21 | exclude: tests\/test_.+\. 22 | 23 | - repo: https://github.com/charliermarsh/ruff-pre-commit 24 | rev: "v0.7.0" 25 | hooks: 26 | - id: ruff 27 | stages: [pre-commit] 28 | - id: ruff-format 29 | stages: [pre-commit] 30 | 31 | - repo: https://github.com/sphinx-contrib/sphinx-lint 32 | rev: "v1.0.0" 33 | hooks: 34 | - id: sphinx-lint 35 | stages: [pre-commit] 36 | 37 | - repo: https://github.com/codespell-project/codespell 38 | rev: v2.2.4 39 | hooks: 40 | - id: codespell 41 | stages: [pre-commit] 42 | additional_dependencies: 43 | - tomli 44 | 45 | # Prevent committing inline conflict markers 46 | - repo: https://github.com/pre-commit/pre-commit-hooks 47 | rev: v4.3.0 48 | hooks: 49 | - id: check-merge-conflict 50 | stages: [pre-commit] 51 | args: [--assume-in-merge] 52 | -------------------------------------------------------------------------------- /tests/check_warnings.py: -------------------------------------------------------------------------------- 1 | """Check the warnings from doc builds.""" 2 | 3 | import sys 4 | from pathlib import Path 5 | 6 | 7 | def check_warnings(file: Path) -> int: 8 | """Check the list of warnings produced by the CI tests. 9 | 10 | Raises errors if there are unexpected ones and/or if some are missing. 11 | 12 | Args: 13 | file: the path to the generated warning.txt file from 14 | the CI build 15 | 16 | Returns: 17 | 0 if the warnings are all there 18 | 1 if some warning are not registered or unexpected 19 | """ 20 | # print some log 21 | print("\n=== Sphinx Warnings test ===\n") 22 | 23 | # find the file where all the known warnings are stored 24 | warning_file = Path(__file__).parent / "data" / "warning_list.txt" 25 | 26 | test_warnings = file.read_text().strip().split("\n") 27 | ref_warnings = warning_file.read_text().strip().split("\n") 28 | 29 | print( 30 | f'Checking build warnings in file: "{file}" and comparing to expected ' 31 | f'warnings defined in "{warning_file}"\n\n' 32 | ) 33 | 34 | # find all the missing warnings 35 | missing_warnings = [] 36 | for wa in ref_warnings: 37 | index = [i for i, twa in enumerate(test_warnings) if wa in twa] 38 | if len(index) == 0: 39 | missing_warnings += [wa] 40 | print(f"Warning was not raised: {wa}") 41 | else: 42 | test_warnings.pop(index[0]) 43 | 44 | # the remaining one are unexpected 45 | for twa in test_warnings: 46 | print(f"Unexpected warning: {twa}") 47 | 48 | # delete the tmp warnings file 49 | file.unlink() 50 | 51 | return len(missing_warnings) != 0 or len(test_warnings) != 0 52 | 53 | 54 | if __name__ == "__main__": 55 | # cast the file to path and resolve to an absolute one 56 | file = Path.cwd() / "warnings.txt" 57 | 58 | # execute the test 59 | sys.exit(check_warnings(file)) 60 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | eebit 3 | ===== 4 | 5 | .. |license| image:: https://img.shields.io/badge/License-MIT-yellow.svg?logo=opensourceinitiative&logoColor=white 6 | :target: LICENSE 7 | :alt: License: MIT 8 | 9 | .. |commit| image:: https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?logo=git&logoColor=white 10 | :target: https://conventionalcommits.org 11 | :alt: conventional commit 12 | 13 | .. |ruff| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 14 | :target: https://github.com/astral-sh/ruff 15 | :alt: ruff badge 16 | 17 | .. |prettier| image:: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?logo=prettier&logoColor=white 18 | :target: https://github.com/prettier/prettier 19 | :alt: prettier badge 20 | 21 | .. |pre-commmit| image:: https://img.shields.io/badge/pre--commit-active-yellow?logo=pre-commit&logoColor=white 22 | :target: https://pre-commit.com/ 23 | :alt: pre-commit 24 | 25 | .. |pypi| image:: https://img.shields.io/pypi/v/eebit?color=blue&logo=pypi&logoColor=white 26 | :target: https://pypi.org/project/eebit/ 27 | :alt: PyPI version 28 | 29 | .. |build| image:: https://img.shields.io/github/actions/workflow/status/fitoprincipe/eebit/unit.yaml?logo=github&logoColor=white 30 | :target: https://github.com/fitoprincipe/eebit/actions/workflows/unit.yaml 31 | :alt: build 32 | 33 | .. |coverage| image:: https://img.shields.io/codecov/c/github/fitoprincipe/eebit?logo=codecov&logoColor=white 34 | :target: https://codecov.io/gh/fitoprincipe/eebit 35 | :alt: Test Coverage 36 | 37 | .. |docs| image:: https://img.shields.io/readthedocs/eebit?logo=readthedocs&logoColor=white 38 | :target: https://eebit.readthedocs.io/en/latest/ 39 | :alt: Documentation Status 40 | 41 | |license| |commit| |ruff| |prettier| |pre-commmit| |pypi| |build| |coverage| |docs| 42 | 43 | Overview 44 | -------- 45 | 46 | read / decode / write bit information encoded in GEE images 47 | 48 | Credits 49 | ------- 50 | 51 | This package was created with `Copier `__ and the `@12rambau/pypackage `__ 0.1.16 project template. 52 | -------------------------------------------------------------------------------- /.github/workflows/pypackage_check.yaml: -------------------------------------------------------------------------------- 1 | name: template update check 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | PIP_ROOT_USER_ACTION: ignore 8 | 9 | jobs: 10 | check_version: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.10" 17 | - name: install dependencies 18 | run: pip install requests 19 | - name: get latest pypackage release 20 | id: get_latest_release 21 | run: | 22 | RELEASE=$(curl -s https://api.github.com/repos/12rambau/pypackage/releases | jq -r '.[0].tag_name') 23 | echo "latest=$RELEASE" >> $GITHUB_OUTPUT 24 | echo "latest release: $RELEASE" 25 | - name: get current pypackage version 26 | id: get_current_version 27 | run: | 28 | RELEASE=$(yq -r "._commit" .copier-answers.yml) 29 | echo "current=$RELEASE" >> $GITHUB_OUTPUT 30 | echo "current release: $RELEASE" 31 | - name: open issue 32 | if: steps.get_current_version.outputs.current != steps.get_latest_release.outputs.latest 33 | uses: rishabhgupta/git-action-issue@v2 34 | with: 35 | token: ${{ secrets.GITHUB_TOKEN }} 36 | title: "Update template to ${{ steps.get_latest_release.outputs.latest }}" 37 | body: | 38 | The package is based on the ${{ steps.get_current_version.outputs.current }} version of [@12rambau/pypackage](https://github.com/12rambau/pypackage). 39 | 40 | The latest version of the template is ${{ steps.get_latest_release.outputs.latest }}. 41 | 42 | Please consider updating the template to the latest version to include all the latest developments. 43 | 44 | Run the following code in your project directory to update the template: 45 | 46 | ``` 47 | copier update --trust --defaults --vcs-ref ${{ steps.get_latest_release.outputs.latest }} 48 | ``` 49 | 50 | > **Note** 51 | > You may need to reinstall ``copier`` and ``jinja2-time`` if they are not available in your environment. 52 | 53 | After solving the merging issues you can push back the changes to your main branch. 54 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from eebit import helpers 4 | 5 | 6 | def test_format_bandname(): 7 | """Test function format_bandname.""" 8 | pass 9 | 10 | 11 | class TestFormatBits: 12 | @pytest.mark.parametrize( 13 | "key, expected", 14 | [ 15 | ("1-classname", "1-1-classname"), 16 | ], 17 | ) 18 | def test_format_bit_key_valid(self, key, expected): 19 | """Test format_bit_key function with valid keys.""" 20 | assert helpers.format_bit_key(key) == expected 21 | 22 | @pytest.mark.parametrize("key", ["a", "a-1", "1-1", "2-1-classname", "1_", "-1", "1-", ""]) 23 | def test_format_bit_key_invalid(self, key): 24 | """Test that format_bit_key raises an error for invalid input.""" 25 | with pytest.raises(ValueError): # Replace ValueError with the actual expected exception 26 | helpers.format_bit_key(key) 27 | 28 | @pytest.mark.parametrize( 29 | "bit_info", 30 | [ 31 | {"1-1-classname": {"0": "cat1", "2": "cat2"}}, # invalid position 2 32 | {"1-1-classname": {"cat0": 0, "cat1": 1}}, # invalid position cat0 33 | {"1-1-classname": {"0": "", "1": "cat2"}}, # empty string value 34 | {"1-1-classname": 123}, # invalid type 35 | ], 36 | ) 37 | def test_format_bit_value_invalid(self, bit_info): 38 | """Test that format_bit_value raises an error for invalid input.""" 39 | with pytest.raises(ValueError): # Replace ValueError with the actual expected exception 40 | helpers.format_bits_info(bit_info) 41 | 42 | @pytest.mark.parametrize( 43 | "bit_info, expected", 44 | [ 45 | ( 46 | {"1-1-classname": "category"}, 47 | {"1-1-classname": {"0": "no category", "1": "category"}}, 48 | ), 49 | ({"2-classname": {"1": "cat1"}}, {"2-2-classname": {"1": "cat1"}}), 50 | ( 51 | {"3-4-classname": {0: "cat1", 1: "cat2", 2: "cat3", 3: "cat4"}}, 52 | {"3-4-classname": {"0": "cat1", "1": "cat2", "2": "cat3", "3": "cat4"}}, 53 | ), 54 | ], 55 | ) 56 | def test_format_bit_value_valid(self, bit_info, expected): 57 | """Test format_bit_value function with valid inputs.""" 58 | assert helpers.format_bits_info(bit_info) == expected 59 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Configuration file for the Sphinx documentation builder. 2 | 3 | This file only contains a selection of the most common options. For a full 4 | list see the documentation: 5 | https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | """ 7 | 8 | # -- Path setup ---------------------------------------------------------------- 9 | from datetime import datetime 10 | 11 | # -- Project information ------------------------------------------------------- 12 | project = "eebit" 13 | author = "Rodrigo Principe" 14 | copyright = f"2025-{datetime.now().year}, {author}" 15 | release = "0.0.0" 16 | 17 | # -- General configuration ----------------------------------------------------- 18 | extensions = [ 19 | "sphinx_copybutton", 20 | "sphinx.ext.napoleon", 21 | "sphinx.ext.viewcode", 22 | "sphinx.ext.intersphinx", 23 | "sphinx_design", 24 | "autoapi.extension", 25 | ] 26 | exclude_patterns = ["**.ipynb_checkpoints"] 27 | templates_path = ["_template"] 28 | 29 | # -- Options for HTML output --------------------------------------------------- 30 | html_theme = "pydata_sphinx_theme" 31 | html_static_path = ["_static"] 32 | html_theme_options = { 33 | "logo": { 34 | "text": project, 35 | }, 36 | "use_edit_page_button": True, 37 | "footer_end": ["theme-version", "pypackage-credit"], 38 | "icon_links": [ 39 | { 40 | "name": "GitHub", 41 | "url": "https://github.com/fitoprincipe/eebit", 42 | "icon": "fa-brands fa-github", 43 | }, 44 | { 45 | "name": "Pypi", 46 | "url": "https://pypi.org/project/eebit/", 47 | "icon": "fa-brands fa-python", 48 | }, 49 | { 50 | "name": "Conda", 51 | "url": "https://anaconda.org/conda-forge/eebit", 52 | "icon": "fa-custom fa-conda", 53 | "type": "fontawesome", 54 | }, 55 | ], 56 | } 57 | html_context = { 58 | "github_user": "fitoprincipe", 59 | "github_repo": "eebit", 60 | "github_version": "", 61 | "doc_path": "docs", 62 | } 63 | html_css_files = ["custom.css"] 64 | 65 | # -- Options for autosummary/autodoc output ------------------------------------ 66 | autodoc_typehints = "description" 67 | autoapi_dirs = ["../eebit"] 68 | autoapi_python_class_content = "init" 69 | autoapi_member_order = "groupwise" 70 | 71 | # -- Options for intersphinx output -------------------------------------------- 72 | intersphinx_mapping = {} 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | .ruff_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | docs/api/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | 133 | # system IDE 134 | .vscode/ 135 | 136 | # image tmp file 137 | *Zone.Identifier 138 | 139 | # debugging notebooks 140 | test.ipynb 141 | 142 | .idea/ -------------------------------------------------------------------------------- /.github/workflows/unit.yaml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: 4 | workflow_call: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | env: 11 | FORCE_COLOR: 1 12 | PIP_ROOT_USER_ACTION: ignore 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.11" 22 | - uses: pre-commit/action@v3.0.0 23 | 24 | mypy: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: actions/setup-python@v5 29 | with: 30 | python-version: "3.11" 31 | - name: Install nox 32 | run: pip install nox[uv] 33 | - name: run mypy checks 34 | run: nox -s mypy 35 | 36 | docs: 37 | needs: [lint, mypy] 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions/setup-python@v5 42 | with: 43 | python-version: "3.11" 44 | - name: Install nox 45 | run: pip install nox[uv] 46 | - name: build static docs 47 | run: nox -s docs 48 | 49 | build: 50 | needs: [lint, mypy] 51 | strategy: 52 | fail-fast: true 53 | matrix: 54 | os: [ubuntu-latest] 55 | python-version: ["3.8", "3.9", "3.10", "3.11"] 56 | include: 57 | - os: macos-latest # macos test 58 | python-version: "3.11" 59 | - os: windows-latest # windows test 60 | python-version: "3.11" 61 | runs-on: ${{ matrix.os }} 62 | steps: 63 | - uses: actions/checkout@v4 64 | - name: Set up Python ${{ matrix.python-version }} 65 | uses: actions/setup-python@v5 66 | with: 67 | python-version: ${{ matrix.python-version }} 68 | - name: Install nox 69 | run: pip install nox[uv] 70 | - name: test with pytest 71 | run: nox -s ci-test 72 | - name: assess dead fixtures 73 | if: ${{ matrix.python-version == '3.10' }} 74 | shell: bash 75 | run: nox -s dead-fixtures 76 | - uses: actions/upload-artifact@v4 77 | if: ${{ matrix.python-version == '3.10' }} 78 | with: 79 | name: coverage 80 | path: coverage.xml 81 | 82 | coverage: 83 | needs: [build] 84 | runs-on: ubuntu-latest 85 | steps: 86 | - uses: actions/download-artifact@v4 87 | with: 88 | name: coverage 89 | path: coverage.xml 90 | - name: codecov 91 | uses: codecov/codecov-action@v4 92 | with: 93 | file: ./coverage.xml 94 | token: ${{ secrets.CODECOV_TOKEN }} 95 | verbose: true 96 | -------------------------------------------------------------------------------- /docs/_static/custom-icon.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Set a custom icon for pypi as it's not available in the fa built-in brands 3 | */ 4 | FontAwesome.library.add( 5 | (faListOldStyle = { 6 | prefix: "fa-custom", 7 | iconName: "conda", 8 | icon: [ 9 | 24, // viewBox width 10 | 24, // viewBox height 11 | [], // ligature 12 | "e001", // unicode codepoint - private use area 13 | "M12.045.033a12.181 12.182 0 00-1.361.078 17.512 17.513 0 011.813 1.433l.48.438-.465.45a15.047 15.048 0 00-1.126 1.205l-.178.215a8.527 8.527 0 01.86-.05 8.154 8.155 0 11-4.286 15.149 15.764 15.765 0 01-1.841.106h-.86a21.847 21.848 0 00.264 2.866 11.966 11.967 0 106.7-21.89zM8.17.678a12.181 12.182 0 00-2.624 1.275 15.506 15.507 0 011.813.43A18.551 18.552 0 018.17.678zM9.423.75a16.237 16.238 0 00-.995 1.998 16.15 16.152 0 011.605.66 6.98 6.98 0 01.43-.509c.234-.286.472-.559.716-.817A15.047 15.048 0 009.423.75zM4.68 2.949a14.969 14.97 0 000 2.336c.587-.065 1.196-.1 1.812-.107a16.617 16.617 0 01.48-1.748 16.48 16.481 0 00-2.292-.481zM3.62 3.5A11.938 11.938 0 001.762 5.88a17.004 17.004 0 011.877-.444A17.39 17.391 0 013.62 3.5zm4.406.287c-.143.437-.265.888-.38 1.347a8.255 8.255 0 011.67-.803c-.423-.2-.845-.38-1.29-.544zM6.3 6.216a14.051 14.052 0 00-1.555.108c.064.523.157 1.038.272 1.554a8.39 8.391 0 011.283-1.662zm-2.55.137a15.313 15.313 0 00-2.602.716h-.078v.079a17.104 17.105 0 001.267 2.544l.043.071.072-.049a16.309 16.31 0 011.734-1.083l.057-.035V8.54a16.867 16.868 0 01-.408-2.094v-.092zM.644 8.095l-.063.2A11.844 11.845 0 000 11.655v.209l.143-.152a17.706 17.707 0 011.584-1.447l.057-.043-.043-.064a16.18 16.18 0 01-1.025-1.87zm3.77 1.253l-.18.1c-.465.273-.93.573-1.375.889l-.065.05.05.064c.309.437.645.867.996 1.276l.137.165v-.208a8.176 8.176 0 01.364-2.15zM2.2 10.853l-.072.05a16.574 16.574 0 00-1.813 1.734l-.058.058.066.057a15.449 15.45 0 001.991 1.483l.072.05.043-.08a16.738 16.74 0 011.053-1.64v-.05l-.043-.05a16.99 16.99 0 01-1.19-1.54zm1.855 2.071l-.121.172a15.363 15.363 0 00-.917 1.433l-.043.072.071.043a16.61 16.61 0 001.562.766l.193.086-.086-.193a8.04 8.04 0 01-.66-2.172zm-3.976.48v.2a11.758 11.759 0 00.946 3.326l.078.186.072-.194a16.215 16.216 0 01.845-2l.057-.063-.064-.043a17.197 17.198 0 01-1.776-1.284zm2.543 1.805l-.035.08a15.764 15.765 0 00-.983 2.479v.08h.086a16.15 16.152 0 002.688.5l.072.007v-.086a17.562 17.563 0 01.164-2.056v-.065H4.55a16.266 16.266 0 01-1.849-.896zm2.544 1.169v.114a17.254 17.255 0 00-.151 1.828v.078h.931c.287 0 .624.014.946 0h.209l-.166-.129a8.011 8.011 0 01-1.64-1.834zm-3.29 2.1l.115.172a11.988 11.988 0 002.502 2.737l.157.129v-.201a22.578 22.58 0 01-.2-2.336v-.071h-.072a16.23 16.23 0 01-2.3-.387z", // svg path (https://simpleicons.org/icons/anaconda.svg) 14 | ], 15 | }), 16 | ); 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "eebit" 7 | version = "0.0.0" 8 | description = "read / decode / write bit information encoded in GEE images" 9 | keywords = [ 10 | "skeleton", 11 | "Python" 12 | ] 13 | classifiers = [ 14 | "Development Status :: 3 - Alpha", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: MIT License", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | ] 22 | requires-python = ">=3.8" 23 | dependencies = [ 24 | "deprecated>=1.2.14", 25 | "earthengine-api", 26 | "geetools", 27 | "geestac" 28 | ] 29 | 30 | [[project.authors]] 31 | name = "Rodrigo Principe" 32 | email = "fitoprincipe82@gmail.com" 33 | 34 | [project.license] 35 | text = "MIT" 36 | 37 | [project.readme] 38 | file = "README.rst" 39 | content-type = "text/x-rst" 40 | 41 | [project.urls] 42 | Homepage = "https://github.com/fitoprincipe/eebit" 43 | 44 | [project.optional-dependencies] 45 | test = [ 46 | "pytest", 47 | "pytest-cov", 48 | "pytest-deadfixtures", 49 | "pytest-gee" 50 | ] 51 | doc = [ 52 | "sphinx>=6.2.1", 53 | "pydata-sphinx-theme", 54 | "sphinx-copybutton", 55 | "sphinx-design", 56 | "sphinx-autoapi" 57 | ] 58 | 59 | [tool.hatch.build.targets.wheel] 60 | only-include = ["eebit"] 61 | 62 | [tool.hatch.envs.default] 63 | dependencies = [ 64 | "pre-commit", 65 | "commitizen", 66 | "nox[uv]" 67 | ] 68 | post-install-commands = ["pre-commit install"] 69 | 70 | [tool.commitizen] 71 | tag_format = "v$major.$minor.$patch$prerelease" 72 | update_changelog_on_bump = false 73 | version = "0.0.0" 74 | version_files = [ 75 | "pyproject.toml:version", 76 | "eebit/__init__.py:__version__", 77 | "docs/conf.py:release", 78 | "CITATION.cff:version" 79 | ] 80 | 81 | [tool.pytest.ini_options] 82 | testpaths = "tests" 83 | 84 | [tool.ruff] 85 | line-length = 100 86 | ignore-init-module-imports = true 87 | fix = true 88 | 89 | [tool.ruff.lint] 90 | select = ["E", "F", "W", "I", "D", "RUF"] 91 | ignore = [ 92 | "E501", # line too long | Black take care of it 93 | "D212", # Multi-line docstring | We use D213 94 | "D101", # Missing docstring in public class | We use D106 95 | ] 96 | 97 | [tool.ruff.lint.flake8-quotes] 98 | docstring-quotes = "double" 99 | 100 | [tool.ruff.lint.pydocstyle] 101 | convention = "google" 102 | 103 | [tool.coverage.run] 104 | source = ["eebit"] 105 | 106 | [tool.mypy] 107 | scripts_are_modules = true 108 | ignore_missing_imports = true 109 | install_types = true 110 | non_interactive = true 111 | warn_redundant_casts = true 112 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """All the process that can be run using nox. 2 | 3 | The nox run are build in isolated environment that will be stored in .nox. to force the venv update, remove the .nox/xxx folder. 4 | """ 5 | 6 | import datetime 7 | import fileinput 8 | 9 | import nox 10 | 11 | nox.options.sessions = ["lint", "test", "docs", "mypy"] 12 | 13 | 14 | @nox.session(reuse_venv=True, venv_backend="uv") 15 | def lint(session): 16 | """Apply the pre-commits.""" 17 | session.install("pre-commit") 18 | session.run("pre-commit", "run", "--all-files", *session.posargs) 19 | 20 | 21 | @nox.session(reuse_venv=True, venv_backend="uv") 22 | def test(session): 23 | """Run the selected tests and report coverage in html.""" 24 | session.install(".[test]") 25 | test_files = session.posargs or ["tests"] 26 | session.run("pytest", "--cov", "--cov-report=html", *test_files) 27 | 28 | 29 | @nox.session(reuse_venv=True, name="ci-test", venv_backend="uv") 30 | def ci_test(session): 31 | """Run all the test and report coverage in xml.""" 32 | session.install(".[test]") 33 | session.run("pytest", "--cov", "--cov-report=xml") 34 | 35 | 36 | @nox.session(reuse_venv=True, name="dead-fixtures", venv_backend="uv") 37 | def dead_fixtures(session): 38 | """Check for dead fixtures within the tests.""" 39 | session.install(".[test]") 40 | session.run("pytest", "--dead-fixtures") 41 | 42 | 43 | @nox.session(reuse_venv=True, venv_backend="uv") 44 | def docs(session): 45 | """Build the documentation.""" 46 | build = session.posargs.pop() if session.posargs else "html" 47 | session.install(".[doc]") 48 | dst, warn = f"docs/_build/{build}", "warnings.txt" 49 | session.run("sphinx-build", "-v", "-b", build, "docs", dst, "-w", warn) 50 | session.run("python", "tests/check_warnings.py") 51 | 52 | 53 | @nox.session(name="mypy", reuse_venv=True, venv_backend="uv") 54 | def mypy(session): 55 | """Run a mypy check of the lib.""" 56 | session.install("mypy") 57 | test_files = session.posargs or ["eebit"] 58 | session.run("mypy", *test_files) 59 | 60 | 61 | @nox.session(reuse_venv=True, venv_backend="uv") 62 | def stubgen(session): 63 | """Generate stub files for the lib but requires human attention before merge.""" 64 | session.install("mypy") 65 | package = session.posargs or ["eebit"] 66 | session.run("stubgen", "-p", package[0], "-o", "stubs", "--include-private") 67 | 68 | 69 | @nox.session(name="release-date", reuse_venv=True, venv_backend="uv") 70 | def release_date(session): 71 | """Update the release date of the citation file.""" 72 | current_date = datetime.datetime.now().strftime("%Y-%m-%d") 73 | 74 | with fileinput.FileInput("CITATION.cff", inplace=True) as file: 75 | for line in file: 76 | if line.startswith("date-released:"): 77 | print(f'date-released: "{current_date}"') 78 | else: 79 | print(line, end="") 80 | -------------------------------------------------------------------------------- /tests/test_bitmask/serialized_test_bitgroup_get_mask_by_position.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | functionInvocationValue: 5 | arguments: 6 | geometry: 7 | functionInvocationValue: 8 | arguments: 9 | coordinates: 10 | constantValue: 11 | - - - -71.72 12 | - -42.96 13 | - - -71.72 14 | - -43.18 15 | - - -71.39 16 | - -43.18 17 | - - -71.39 18 | - -42.96 19 | evenOdd: 20 | constantValue: true 21 | functionName: GeometryConstructors.Polygon 22 | input: 23 | functionInvocationValue: 24 | arguments: 25 | image: 26 | valueReference: '1' 27 | mask: 28 | functionInvocationValue: 29 | arguments: 30 | input: 31 | functionInvocationValue: 32 | arguments: 33 | image1: 34 | functionInvocationValue: 35 | arguments: 36 | image1: 37 | functionInvocationValue: 38 | arguments: 39 | image1: 40 | functionInvocationValue: 41 | arguments: 42 | bandSelectors: 43 | constantValue: 44 | - QA_PIXEL 45 | input: 46 | valueReference: '1' 47 | functionName: Image.select 48 | image2: 49 | functionInvocationValue: 50 | arguments: 51 | value: 52 | constantValue: 8 53 | functionName: Image.constant 54 | functionName: Image.rightShift 55 | image2: 56 | valueReference: '2' 57 | functionName: Image.bitwiseAnd 58 | image2: 59 | valueReference: '2' 60 | functionName: Image.eq 61 | names: 62 | constantValue: 63 | - cloud - high 64 | functionName: Image.rename 65 | functionName: Image.updateMask 66 | scale: 67 | constantValue: 30 68 | functionName: Image.clipToBoundsAndScale 69 | '1': 70 | functionInvocationValue: 71 | arguments: 72 | id: 73 | constantValue: LANDSAT/LC08/C02/T1_L2/LC08_231090_20240107 74 | functionName: Image.load 75 | '2': 76 | functionInvocationValue: 77 | arguments: 78 | value: 79 | constantValue: 3 80 | functionName: Image.constant 81 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contribute 2 | ========== 3 | 4 | Thank you for your help improving **eebit**! 5 | 6 | **eebit** uses `nox `__ to automate several development-related tasks. 7 | Currently, the project uses four automation processes (called sessions) in ``noxfile.py``: 8 | 9 | - ``mypy``: to perform a mypy check on the lib; 10 | - ``test``: to run the test with pytest; 11 | - ``docs``: to build the documentation in the ``build`` folder; 12 | - ``lint``: to run the pre-commits in an isolated environment 13 | 14 | Every nox session is run in its own virtual environment, and the dependencies are installed automatically. 15 | 16 | To run a specific nox automation process, use the following command: 17 | 18 | .. code-block:: console 19 | 20 | nox -s 21 | 22 | For example: ``nox -s test`` or ``nox -s docs``. 23 | 24 | Workflow for contributing changes 25 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 26 | 27 | We follow a typical GitHub workflow of: 28 | 29 | - Create a personal fork of this repo 30 | - Create a branch 31 | - Open a pull request 32 | - Fix findings of various linters and checks 33 | - Work through code review 34 | 35 | See the following sections for more details. 36 | 37 | Clone the repository 38 | ^^^^^^^^^^^^^^^^^^^^ 39 | 40 | First off, you'll need your own copy of **eebit** codebase. You can clone it for local development like so: 41 | 42 | Fork the repository so you have your own copy on GitHub. See the `GitHub forking guide for more information `__. 43 | 44 | Then, clone the repository locally so that you have a local copy to work on: 45 | 46 | .. code-block:: console 47 | 48 | git clone https://github.com//eebit 49 | cd eebit 50 | 51 | Then install the development version of the extension: 52 | 53 | .. code-block:: console 54 | 55 | pip install -e .[dev] 56 | 57 | This will install the **eebit** library, together with two additional tools: 58 | - `pre-commit `__ for automatically enforcing code standards and quality checks before commits. 59 | - `nox `__, for automating common development tasks. 60 | 61 | Lastly, activate the pre-commit hooks by running: 62 | 63 | .. code-block:: console 64 | 65 | pre-commit install 66 | 67 | This will install the necessary dependencies to run pre-commit every time you make a commit with Git. 68 | 69 | Contribute to the codebase 70 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 71 | 72 | Any larger updates to the codebase should include tests and documentation. The tests are located in the ``tests`` folder, and the documentation is located in the ``docs`` folder. 73 | 74 | To run the tests locally, use the following command: 75 | 76 | .. code-block:: console 77 | 78 | nox -s test 79 | 80 | See :ref:`below ` for more information on how to update the documentation. 81 | 82 | .. _contributing-docs: 83 | 84 | Contribute to the docs 85 | ^^^^^^^^^^^^^^^^^^^^^^ 86 | 87 | The documentation is built using `Sphinx `__ and deployed to `Read the Docs `__. 88 | 89 | To build the documentation locally, use the following command: 90 | 91 | .. code-block:: console 92 | 93 | nox -s docs 94 | 95 | For each pull request, the documentation is built and deployed to make it easier to review the changes in the PR. To access the docs build from a PR, click on the "Read the Docs" preview in the CI/CD jobs. 96 | 97 | Release new version 98 | ^^^^^^^^^^^^^^^^^^^ 99 | 100 | To release a new version, start by pushing a new bump from the local directory: 101 | 102 | .. code-block:: 103 | 104 | cz bump 105 | 106 | The commitizen-tool will detect the semantic version name based on the existing commits messages. 107 | 108 | Then push to Github. In Github design a new release using the same tag name nad the ``release.yaml`` job will send it to pipy. 109 | -------------------------------------------------------------------------------- /eebit/bithandler.py: -------------------------------------------------------------------------------- 1 | """bit handler.""" 2 | 3 | import ee 4 | import geestac 5 | 6 | from eebit import helpers 7 | 8 | 9 | class BitHandler: 10 | """Bit handler.""" 11 | 12 | def __init__(self, bits: dict, bit_length: int | None = None): 13 | """Bit handler. 14 | 15 | Read / decode / write bits information encoded in Google Earth Engine images. 16 | 17 | Args: 18 | bits (dict): a dictionary containing the bits information in the following format: 19 | 20 | { 21 | "1-catname": "category", # option 1, one category 22 | "2-3-catname": { 23 | "1": "cat1", 24 | "2": "cat2", 25 | "3": "cat3", 26 | "4": "cat4" 27 | } # option 2, 2 or more bits. 28 | 29 | where "catname" is the name of the whole category represented by the group of bits 30 | 31 | Example (Landsat 9 QA_PIXEL band: https://developers.google.com/earth-engine/datasets/catalog/LANDSAT_LC09_C02_T1_L2#bands) 32 | 33 | bits = { 34 | "3-Cloud": "Cloud", 35 | "4-Cloud Shadow": "Cloud Shadow", 36 | "5": "Snow", 37 | "7": "Water", 38 | "8-9": { 39 | "1": "Clouds Low Prob", 40 | "2": "Clouds Medium Prob", 41 | "3": "Clouds High Prob" 42 | }, 43 | "10-11": { 44 | "1": "Shadow Low Prob", 45 | "2": "Shadow Medium Prob", 46 | "3": "Shadow High Prob" 47 | } 48 | } 49 | 50 | bit_length: the length of the 51 | """ 52 | self.bits = helpers.format_bits_info(bits) 53 | self._all_bits = None 54 | self.bit_length = ( 55 | len(range(min(self.all_bits), max(self.all_bits) + 1)) if not bit_length else bit_length 56 | ) 57 | 58 | @property 59 | def all_bits(self) -> list: 60 | """List of all bits.""" 61 | if self._all_bits is None: 62 | allbits = [] 63 | for key in self.bits.keys(): 64 | decoded = helpers.decode_key(key) 65 | for bit in decoded: 66 | if bit in allbits: 67 | raise ValueError(f"bit {bit} is duplicated!") 68 | allbits.append(bit) 69 | self._all_bits = allbits 70 | return self._all_bits 71 | 72 | def decode_image(self, image: ee.Image, band: int | str = 0) -> ee.Image: 73 | """Decode a bit band of an ee.Image. 74 | 75 | The band of the image must correspond to the bits of the BitHandler object. 76 | 77 | Args: 78 | image: the image that contains the band to decode. 79 | band (optional): the bit band. If None it uses the first band of the image. 80 | 81 | Returns: 82 | A new image with one band per class. The image properties ara NOT passed to the new image. 83 | """ 84 | to_decode = image.select([band]) 85 | masks = [] 86 | for positions, values in self.bits.items(): 87 | start, end = positions.split("-") 88 | start, end = ee.Image(int(start)), ee.Image(int(end)).add(1) 89 | decoded = ( 90 | to_decode.rightShift(end).leftShift(end).bitwiseXor(to_decode).rightShift(start) 91 | ) 92 | for position, val in values.items(): 93 | mask = ee.Image(int(position)).eq(decoded).rename(val) 94 | masks.append(mask) 95 | return ee.Image.geetools.fromList(masks) 96 | 97 | @classmethod 98 | def from_asset(cls, asset_id: str, band: str, bit_length: int | None = None) -> "BitHandler": 99 | """Create an instance of BitHandler using the bits information fetched with geestac.""" 100 | stac = geestac.fromId(asset_id) 101 | bits_info = stac.bands[band].bitmask.to_dict() 102 | return cls(bits_info, bit_length) 103 | -------------------------------------------------------------------------------- /tests/test_bitmask/serialized_test_bitgroup_decode_to_columns.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | functionInvocationValue: 5 | arguments: 6 | baseAlgorithm: 7 | functionDefinitionValue: 8 | argumentNames: 9 | - _MAPPING_VAR_0_0 10 | body: '1' 11 | collection: 12 | functionInvocationValue: 13 | arguments: 14 | baseAlgorithm: 15 | functionDefinitionValue: 16 | argumentNames: 17 | - _MAPPING_VAR_0_0 18 | body: '3' 19 | collection: 20 | functionInvocationValue: 21 | arguments: 22 | baseAlgorithm: 23 | functionDefinitionValue: 24 | argumentNames: 25 | - _MAPPING_VAR_0_0 26 | body: '4' 27 | collection: 28 | functionInvocationValue: 29 | arguments: 30 | collection: 31 | functionInvocationValue: 32 | arguments: 33 | points: 34 | constantValue: 10 35 | region: 36 | functionInvocationValue: 37 | arguments: 38 | coordinates: 39 | constantValue: 40 | - - - -71.72 41 | - -42.96 42 | - - -71.72 43 | - -43.18 44 | - - -71.39 45 | - -43.18 46 | - - -71.39 47 | - -42.96 48 | evenOdd: 49 | constantValue: true 50 | functionName: GeometryConstructors.Polygon 51 | functionName: FeatureCollection.randomPoints 52 | image: 53 | functionInvocationValue: 54 | arguments: 55 | id: 56 | constantValue: LANDSAT/LC08/C02/T1_L2/LC08_231090_20240107 57 | functionName: Image.load 58 | reducer: 59 | functionInvocationValue: 60 | arguments: {} 61 | functionName: Reducer.first 62 | scale: 63 | constantValue: 30 64 | functionName: Image.reduceRegions 65 | functionName: Collection.map 66 | functionName: Collection.map 67 | functionName: Collection.map 68 | '1': 69 | functionInvocationValue: 70 | arguments: 71 | key: 72 | constantValue: cloud - high 73 | object: 74 | argumentReference: _MAPPING_VAR_0_0 75 | value: 76 | functionInvocationValue: 77 | arguments: 78 | left: 79 | valueReference: '2' 80 | right: 81 | constantValue: 3 82 | functionName: Number.eq 83 | functionName: Element.set 84 | '2': 85 | functionInvocationValue: 86 | arguments: 87 | left: 88 | functionInvocationValue: 89 | arguments: 90 | left: 91 | functionInvocationValue: 92 | arguments: 93 | object: 94 | argumentReference: _MAPPING_VAR_0_0 95 | property: 96 | constantValue: QA_PIXEL 97 | functionName: Element.get 98 | right: 99 | constantValue: 8 100 | functionName: Number.rightShift 101 | right: 102 | constantValue: 3 103 | functionName: Number.bitwiseAnd 104 | '3': 105 | functionInvocationValue: 106 | arguments: 107 | key: 108 | constantValue: cloud - medium 109 | object: 110 | argumentReference: _MAPPING_VAR_0_0 111 | value: 112 | functionInvocationValue: 113 | arguments: 114 | left: 115 | valueReference: '2' 116 | right: 117 | constantValue: 2 118 | functionName: Number.eq 119 | functionName: Element.set 120 | '4': 121 | functionInvocationValue: 122 | arguments: 123 | key: 124 | constantValue: cloud - low 125 | object: 126 | argumentReference: _MAPPING_VAR_0_0 127 | value: 128 | functionInvocationValue: 129 | arguments: 130 | left: 131 | valueReference: '2' 132 | right: 133 | constantValue: 1 134 | functionName: Number.eq 135 | functionName: Element.set 136 | -------------------------------------------------------------------------------- /eebit/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions.""" 2 | 3 | BANNED_BAND_CHAR = list(".*?[]{}+$^+()|") 4 | 5 | 6 | def format_bandname(name: str, replacement: str = "_") -> str: 7 | """Format a band name to be allowed in GEE.""" 8 | return str([name.replace(char, replacement) for char in BANNED_BAND_CHAR][0]) 9 | 10 | 11 | def is_int(value: str | int) -> bool: 12 | """Check if a string can be converted to an integer.""" 13 | try: 14 | int(value) 15 | except ValueError: 16 | return False 17 | return True 18 | 19 | 20 | def is_str(value: str) -> bool: 21 | """Check if a value is a string.""" 22 | return not is_int(value) and len(value) > 0 23 | 24 | 25 | def format_bit_key(bit: str) -> str: 26 | """Format a bit key.""" 27 | parts = str(bit).split("-", 2) 28 | if len(parts) == 1: 29 | raise ValueError(f"Bad format for '{bit}'. Use 'start-end-catname'") 30 | if len(parts) == 2: 31 | if is_int(parts[0]) and is_str(parts[1]): 32 | parts = [parts[0], parts[0], parts[1]] 33 | else: 34 | raise ValueError(f"Bad format for '{bit}'. Use 'start-end-catname'") 35 | if len(parts) == 3: 36 | if is_int(parts[0]) and is_int(parts[1]) and parts[0] > parts[1]: 37 | raise ValueError(f"In bit {bit}, start bit must be less than or equal to end bit.") 38 | if not is_int(parts[0]) or not is_int(parts[1]): 39 | raise ValueError(f"Bad format for '{bit}'. Use 'start-end-catname'") 40 | return "-".join(parts) 41 | 42 | 43 | def format_bit_value(value: str | dict) -> dict: 44 | """Format a bit value.""" 45 | if isinstance(value, str): 46 | # assume 0 is the opposite of 1 47 | # return {"0": f"no {value}", "1": value} 48 | return {"1": value} 49 | elif isinstance(value, dict): 50 | formatted_value = {} 51 | for pos, val in value.items(): 52 | if not is_int(pos): 53 | raise ValueError(f"Bit position '{pos}' must be an integer.") 54 | if not is_str(val): 55 | raise ValueError(f"Bit value '{val}' must be a non-empty string.") 56 | formatted_value[str(int(pos))] = val 57 | return formatted_value 58 | else: 59 | raise ValueError(f"Bit value must be a string or a dict, found {type(value)}.") 60 | 61 | 62 | def format_bits_info(bits_info: dict) -> dict: 63 | """Format the bits information to match the expected. 64 | 65 | Expected bit class format: 66 | { 67 | "1-1-catname": "category", # option 1, one category 68 | "2-2-catname": { 69 | "0": "cat1", 70 | "1": "cat2" 71 | }, # option 2, 2 or more bits. 72 | "3-4-catname": { 73 | "0": "cat1", 74 | "1": "cat2", 75 | "2": "cat3", 76 | "3": "cat4" 77 | } # option 2, 2 or more bits. 78 | } 79 | 80 | Args: 81 | bits_info: the bits information. 82 | 83 | Example: 84 | .. code-block:: python 85 | 86 | from geetools.utils import format_bitmask 87 | 88 | bitmask = { 89 | '0-shadows condition': 'shadows', 90 | '1-2-cloud conditions': { 91 | '0': 'no clouds', 92 | '1': 'high clouds', 93 | '2': 'mid clouds', 94 | '3': 'low clouds' 95 | } 96 | } 97 | bitmask = format_bitmask(bitmask) 98 | """ 99 | final_bit_info = {} 100 | classes = [] 101 | 102 | for bit, info in bits_info.items(): 103 | parts = str(bit).split("-", 2) 104 | if len(parts) == 1 and is_int(parts[0]): 105 | # when one bit is provided without description, use info to get description 106 | if isinstance(info, dict) and len(info) <= 2: 107 | bit_1 = info.get("1", info.get(1)) 108 | if bit_1 is None: 109 | raise ValueError( 110 | f"For single bit the positive value must be set. Found: {info}" 111 | ) 112 | bit = f"{parts[0]}-{parts[0]}-{bit_1}" 113 | elif isinstance(info, str): 114 | bit = f"{parts[0]}-{parts[0]}-{info}" 115 | else: 116 | raise ValueError(f"For single bit the positive value must be set. Found: {info}") 117 | bit = format_bit_key(bit) 118 | start, end, catname = bit.split("-", 2) 119 | start, end = int(start), int(end) 120 | nbits = end - start + 1 121 | if nbits < 1: 122 | raise ValueError(f"In bit {bit}, start bit must be less than or equal to end bit.") 123 | if catname in classes: 124 | raise ValueError( 125 | f"Bits information cannot contain duplicated names. '{catname}' is duplicated." 126 | ) 127 | classes.append(catname) 128 | if not isinstance(info, (str, dict)): 129 | raise ValueError(f"Bit value must be a string or a dict, found {type(info)}.") 130 | formatted_info = format_bit_value(info) 131 | if len(formatted_info) > 2**nbits: 132 | raise ValueError( 133 | f"Number of values in bit '{bit}' exceeds the number of bits ({nbits})." 134 | ) 135 | positions = [int(pos) for pos in formatted_info.keys()] 136 | if any(pos >= 2**nbits for pos in positions): 137 | raise ValueError( 138 | f"One or more positions in bit '{bit}' are out of range for the number of bits ({nbits})." 139 | ) 140 | final_bit_info[bit] = formatted_info 141 | return final_bit_info 142 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | Contributor Covenant Code of Conduct 2 | ==================================== 3 | 4 | Our Pledge 5 | ---------- 6 | 7 | We as members, contributors, and leaders pledge to make participation in our 8 | community a harassment-free experience for everyone, regardless of age, body 9 | size, visible or invisible disability, ethnicity, sex characteristics, gender 10 | identity and expression, level of experience, education, socio-economic status, 11 | nationality, personal appearance, race, religion, or sexual identity 12 | and orientation. 13 | 14 | We pledge to act and interact in ways that contribute to an open, welcoming, 15 | diverse, inclusive, and healthy community. 16 | 17 | Our Standards 18 | ------------- 19 | 20 | Examples of behavior that contributes to a positive environment for our 21 | community include: 22 | 23 | * Demonstrating empathy and kindness toward other people 24 | * Being respectful of differing opinions, viewpoints, and experiences 25 | * Giving and gracefully accepting constructive feedback 26 | * Accepting responsibility and apologizing to those affected by our mistakes, 27 | and learning from the experience 28 | * Focusing on what is best not just for us as individuals, but for the 29 | overall community 30 | 31 | Examples of unacceptable behavior include: 32 | 33 | * The use of sexualized language or imagery, and sexual attention or 34 | advances of any kind 35 | * Trolling, insulting or derogatory comments, and personal or political attacks 36 | * Public or private harassment 37 | * Publishing others' private information, such as a physical or email 38 | address, without their explicit permission 39 | * Other conduct which could reasonably be considered inappropriate in a 40 | professional setting 41 | 42 | Enforcement Responsibilities 43 | ---------------------------- 44 | 45 | Community leaders are responsible for clarifying and enforcing our standards of 46 | acceptable behavior and will take appropriate and fair corrective action in 47 | response to any behavior that they deem inappropriate, threatening, offensive, 48 | or harmful. 49 | 50 | Community leaders have the right and responsibility to remove, edit, or reject 51 | comments, commits, code, wiki edits, issues, and other contributions that are 52 | not aligned to this Code of Conduct, and will communicate reasons for moderation 53 | decisions when appropriate. 54 | 55 | Scope 56 | ----- 57 | 58 | This Code of Conduct applies within all community spaces, and also applies when 59 | an individual is officially representing the community in public spaces. 60 | Examples of representing our community include using an official e-mail address, 61 | posting via an official social media account, or acting as an appointed 62 | representative at an online or offline event. 63 | 64 | Enforcement 65 | ----------- 66 | 67 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 68 | reported to the FAO team responsible for enforcement at 69 | pierrick.rambaud49@gmail.com. 70 | All complaints will be reviewed and investigated promptly and fairly. 71 | 72 | All community leaders are obligated to respect the privacy and security of the 73 | reporter of any incident. 74 | 75 | Enforcement Guidelines 76 | ---------------------- 77 | 78 | Community leaders will follow these Community Impact Guidelines in determining 79 | the consequences for any action they deem in violation of this Code of Conduct: 80 | 81 | Correction 82 | ^^^^^^^^^^ 83 | 84 | **Community Impact**: Use of inappropriate language or other behavior deemed 85 | unprofessional or unwelcome in the community. 86 | 87 | **Consequence**: A private, written warning from community leaders, providing 88 | clarity around the nature of the violation and an explanation of why the 89 | behavior was inappropriate. A public apology may be requested. 90 | 91 | Warning 92 | ^^^^^^^ 93 | 94 | **Community Impact**: A violation through a single incident or series 95 | of actions. 96 | 97 | **Consequence**: A warning with consequences for continued behavior. No 98 | interaction with the people involved, including unsolicited interaction with 99 | those enforcing the Code of Conduct, for a specified period of time. This 100 | includes avoiding interactions in community spaces as well as external channels 101 | like social media. Violating these terms may lead to a temporary or 102 | permanent ban. 103 | 104 | Temporary Ban 105 | ^^^^^^^^^^^^^ 106 | 107 | **Community Impact**: A serious violation of community standards, including 108 | sustained inappropriate behavior. 109 | 110 | **Consequence**: A temporary ban from any sort of interaction or public 111 | communication with the community for a specified period of time. No public or 112 | private interaction with the people involved, including unsolicited interaction 113 | with those enforcing the Code of Conduct, is allowed during this period. 114 | Violating these terms may lead to a permanent ban. 115 | 116 | Permanent Ban 117 | ^^^^^^^^^^^^^ 118 | 119 | **Community Impact**: Demonstrating a pattern of violation of community 120 | standards, including sustained inappropriate behavior, harassment of an 121 | individual, or aggression toward or disparagement of classes of individuals. 122 | 123 | **Consequence**: A permanent ban from any sort of public interaction within 124 | the community. 125 | 126 | Attribution 127 | ----------- 128 | 129 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 130 | version 2.0, available at 131 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 132 | 133 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 134 | enforcement ladder](https://github.com/mozilla/diversity). 135 | 136 | [homepage]: https://www.contributor-covenant.org 137 | 138 | For answers to common questions about this code of conduct, see the FAQ at 139 | https://www.contributor-covenant.org/faq. Translations are available at 140 | https://www.contributor-covenant.org/translations. 141 | -------------------------------------------------------------------------------- /tests/test_bitmask/test_bitgroup_decode_to_columns.yml: -------------------------------------------------------------------------------- 1 | features: 2 | - geometry: 3 | coordinates: 4 | - -71.518599 5 | - -43.128463 6 | type: Point 7 | id: '0' 8 | properties: 9 | QA_PIXEL: 21824 10 | QA_RADSAT: 0 11 | SR_B1: 8475 12 | SR_B2: 8851 13 | SR_B3: 9942 14 | SR_B4: 10703 15 | SR_B5: 20646 16 | SR_B6: 14717 17 | SR_B7: 10912 18 | SR_QA_AEROSOL: 96 19 | ST_ATRAN: 8246 20 | ST_B10: 44618 21 | ST_CDIST: 370 22 | ST_DRAD: 594 23 | ST_EMIS: 9837 24 | ST_EMSD: 141 25 | ST_QA: 224 26 | ST_TRAD: 9132 27 | ST_URAD: 1150 28 | cloud - high: 0 29 | cloud - low: 1 30 | cloud - medium: 0 31 | type: Feature 32 | - geometry: 33 | coordinates: 34 | - -71.400233 35 | - -42.967927 36 | type: Point 37 | id: '1' 38 | properties: 39 | QA_PIXEL: 21824 40 | QA_RADSAT: 0 41 | SR_B1: 8757 42 | SR_B2: 9118 43 | SR_B3: 10082 44 | SR_B4: 10268 45 | SR_B5: 18908 46 | SR_B6: 14170 47 | SR_B7: 11132 48 | SR_QA_AEROSOL: 160 49 | ST_ATRAN: 8293 50 | ST_B10: 44621 51 | ST_CDIST: 455 52 | ST_DRAD: 578 53 | ST_EMIS: 9831 54 | ST_EMSD: 38 55 | ST_QA: 207 56 | ST_TRAD: 9137 57 | ST_URAD: 1113 58 | cloud - high: 0 59 | cloud - low: 1 60 | cloud - medium: 0 61 | type: Feature 62 | - geometry: 63 | coordinates: 64 | - -71.457013 65 | - -43.043511 66 | type: Point 67 | id: '2' 68 | properties: 69 | QA_PIXEL: 21824 70 | QA_RADSAT: 0 71 | SR_B1: 9188 72 | SR_B2: 9497 73 | SR_B3: 10704 74 | SR_B4: 11100 75 | SR_B5: 18163 76 | SR_B6: 15896 77 | SR_B7: 13516 78 | SR_QA_AEROSOL: 96 79 | ST_ATRAN: 8301 80 | ST_B10: 45713 81 | ST_CDIST: 48 82 | ST_DRAD: 576 83 | ST_EMIS: 9553 84 | ST_EMSD: 151 85 | ST_QA: 373 86 | ST_TRAD: 9362 87 | ST_URAD: 1110 88 | cloud - high: 0 89 | cloud - low: 1 90 | cloud - medium: 0 91 | type: Feature 92 | - geometry: 93 | coordinates: 94 | - -71.421715 95 | - -43.174217 96 | type: Point 97 | id: '3' 98 | properties: 99 | QA_PIXEL: 21824 100 | QA_RADSAT: 0 101 | SR_B1: 8207 102 | SR_B2: 8474 103 | SR_B3: 9804 104 | SR_B4: 9442 105 | SR_B5: 21391 106 | SR_B6: 13786 107 | SR_B7: 10443 108 | SR_QA_AEROSOL: 160 109 | ST_ATRAN: 8428 110 | ST_B10: 43731 111 | ST_CDIST: 207 112 | ST_DRAD: 527 113 | ST_EMIS: 9676 114 | ST_EMSD: 155 115 | ST_QA: 279 116 | ST_TRAD: 8685 117 | ST_URAD: 1007 118 | cloud - high: 0 119 | cloud - low: 1 120 | cloud - medium: 0 121 | type: Feature 122 | - geometry: 123 | coordinates: 124 | - -71.692487 125 | - -43.095786 126 | type: Point 127 | id: '4' 128 | properties: 129 | QA_PIXEL: 21952 130 | QA_RADSAT: 0 131 | SR_B1: 7660 132 | SR_B2: 7757 133 | SR_B3: 7539 134 | SR_B4: 7137 135 | SR_B5: 7214 136 | SR_B6: 7369 137 | SR_B7: 7419 138 | SR_QA_AEROSOL: 164 139 | ST_ATRAN: 8281 140 | ST_B10: 40731 141 | ST_CDIST: 73 142 | ST_DRAD: 575 143 | ST_EMIS: 9880 144 | ST_EMSD: 0 145 | ST_QA: 355 146 | ST_TRAD: 7675 147 | ST_URAD: 1112 148 | cloud - high: 0 149 | cloud - low: 1 150 | cloud - medium: 0 151 | type: Feature 152 | - geometry: 153 | coordinates: 154 | - -71.717352 155 | - -42.998725 156 | type: Point 157 | id: '5' 158 | properties: 159 | QA_PIXEL: 23888 160 | QA_RADSAT: 0 161 | SR_B1: 7234 162 | SR_B2: 7267 163 | SR_B3: 7409 164 | SR_B4: 7168 165 | SR_B5: 9826 166 | SR_B6: 7890 167 | SR_B7: 7526 168 | SR_QA_AEROSOL: 164 169 | ST_ATRAN: 8313 170 | ST_B10: 40498 171 | ST_CDIST: 33 172 | ST_DRAD: 559 173 | ST_EMIS: 9904 174 | ST_EMSD: 209 175 | ST_QA: 465 176 | ST_TRAD: 7598 177 | ST_URAD: 1079 178 | cloud - high: 0 179 | cloud - low: 1 180 | cloud - medium: 0 181 | type: Feature 182 | - geometry: 183 | coordinates: 184 | - -71.611959 185 | - -43.151053 186 | type: Point 187 | id: '6' 188 | properties: 189 | QA_PIXEL: 22280 190 | QA_RADSAT: 0 191 | SR_B1: 21557 192 | SR_B2: 22022 193 | SR_B3: 22455 194 | SR_B4: 22805 195 | SR_B5: 27873 196 | SR_B6: 23098 197 | SR_B7: 21153 198 | SR_QA_AEROSOL: 96 199 | ST_ATRAN: 8251 200 | ST_B10: 37429 201 | ST_CDIST: 0 202 | ST_DRAD: 589 203 | ST_EMIS: 9785 204 | ST_EMSD: 59 205 | ST_QA: 632 206 | ST_TRAD: 6511 207 | ST_URAD: 1141 208 | cloud - high: 1 209 | cloud - low: 0 210 | cloud - medium: 0 211 | type: Feature 212 | - geometry: 213 | coordinates: 214 | - -71.682779 215 | - -43.178612 216 | type: Point 217 | id: '7' 218 | properties: 219 | QA_PIXEL: 21824 220 | QA_RADSAT: 0 221 | SR_B1: 8454 222 | SR_B2: 8504 223 | SR_B3: 9072 224 | SR_B4: 8647 225 | SR_B5: 15291 226 | SR_B6: 12358 227 | SR_B7: 10203 228 | SR_QA_AEROSOL: 96 229 | ST_ATRAN: 8260 230 | ST_B10: 43558 231 | ST_CDIST: 47 232 | ST_DRAD: 584 233 | ST_EMIS: 9805 234 | ST_EMSD: 68 235 | ST_QA: 376 236 | ST_TRAD: 8685 237 | ST_URAD: 1131 238 | cloud - high: 0 239 | cloud - low: 1 240 | cloud - medium: 0 241 | type: Feature 242 | - geometry: 243 | coordinates: 244 | - -71.639914 245 | - -42.988605 246 | type: Point 247 | id: '8' 248 | properties: 249 | QA_PIXEL: 22280 250 | QA_RADSAT: 0 251 | SR_B1: 7419 252 | SR_B2: 8856 253 | SR_B3: 9832 254 | SR_B4: 10910 255 | SR_B5: 20082 256 | SR_B6: 15667 257 | SR_B7: 14434 258 | SR_QA_AEROSOL: 224 259 | ST_ATRAN: 8670 260 | ST_B10: 36914 261 | ST_CDIST: 0 262 | ST_DRAD: 434 263 | ST_EMIS: 9829 264 | ST_EMSD: 28 265 | ST_QA: 637 266 | ST_TRAD: 6303 267 | ST_URAD: 812 268 | cloud - high: 1 269 | cloud - low: 0 270 | cloud - medium: 0 271 | type: Feature 272 | - geometry: 273 | coordinates: 274 | - -71.599761 275 | - -43.169497 276 | type: Point 277 | id: '9' 278 | properties: 279 | QA_PIXEL: 21824 280 | QA_RADSAT: 0 281 | SR_B1: 7909 282 | SR_B2: 7947 283 | SR_B3: 8659 284 | SR_B4: 8242 285 | SR_B5: 15091 286 | SR_B6: 11310 287 | SR_B7: 9269 288 | SR_QA_AEROSOL: 130 289 | ST_ATRAN: 8228 290 | ST_B10: 43956 291 | ST_CDIST: 76 292 | ST_DRAD: 598 293 | ST_EMIS: 9845 294 | ST_EMSD: 82 295 | ST_QA: 344 296 | ST_TRAD: 8869 297 | ST_URAD: 1161 298 | cloud - high: 0 299 | cloud - low: 1 300 | cloud - medium: 0 301 | type: Feature 302 | type: FeatureCollection 303 | -------------------------------------------------------------------------------- /tests/test_bitmask/test_bitmask_decode_to_columns.yml: -------------------------------------------------------------------------------- 1 | features: 2 | - geometry: 3 | coordinates: 4 | - -71.518599 5 | - -43.128463 6 | type: Point 7 | id: '0' 8 | properties: 9 | QA_PIXEL: 21824 10 | QA_RADSAT: 0 11 | SR_B1: 8475 12 | SR_B2: 8851 13 | SR_B3: 9942 14 | SR_B4: 10703 15 | SR_B5: 20646 16 | SR_B6: 14717 17 | SR_B7: 10912 18 | SR_QA_AEROSOL: 96 19 | ST_ATRAN: 8246 20 | ST_B10: 44618 21 | ST_CDIST: 370 22 | ST_DRAD: 594 23 | ST_EMIS: 9837 24 | ST_EMSD: 141 25 | ST_QA: 224 26 | ST_TRAD: 9132 27 | ST_URAD: 1150 28 | cloud - cloud: 0 29 | cloud confidence - clouds high prob: 0 30 | cloud confidence - clouds low prob: 1 31 | cloud confidence - clouds medium prob: 0 32 | cloud shadow - cloud shadow: 0 33 | shadow confidence - shadow high prob: 0 34 | shadow confidence - shadow low prob: 1 35 | shadow confidence - shadow medium prob: 0 36 | snow - snow: 0 37 | water - water: 0 38 | type: Feature 39 | - geometry: 40 | coordinates: 41 | - -71.400233 42 | - -42.967927 43 | type: Point 44 | id: '1' 45 | properties: 46 | QA_PIXEL: 21824 47 | QA_RADSAT: 0 48 | SR_B1: 8757 49 | SR_B2: 9118 50 | SR_B3: 10082 51 | SR_B4: 10268 52 | SR_B5: 18908 53 | SR_B6: 14170 54 | SR_B7: 11132 55 | SR_QA_AEROSOL: 160 56 | ST_ATRAN: 8293 57 | ST_B10: 44621 58 | ST_CDIST: 455 59 | ST_DRAD: 578 60 | ST_EMIS: 9831 61 | ST_EMSD: 38 62 | ST_QA: 207 63 | ST_TRAD: 9137 64 | ST_URAD: 1113 65 | cloud - cloud: 0 66 | cloud confidence - clouds high prob: 0 67 | cloud confidence - clouds low prob: 1 68 | cloud confidence - clouds medium prob: 0 69 | cloud shadow - cloud shadow: 0 70 | shadow confidence - shadow high prob: 0 71 | shadow confidence - shadow low prob: 1 72 | shadow confidence - shadow medium prob: 0 73 | snow - snow: 0 74 | water - water: 0 75 | type: Feature 76 | - geometry: 77 | coordinates: 78 | - -71.457013 79 | - -43.043511 80 | type: Point 81 | id: '2' 82 | properties: 83 | QA_PIXEL: 21824 84 | QA_RADSAT: 0 85 | SR_B1: 9188 86 | SR_B2: 9497 87 | SR_B3: 10704 88 | SR_B4: 11100 89 | SR_B5: 18163 90 | SR_B6: 15896 91 | SR_B7: 13516 92 | SR_QA_AEROSOL: 96 93 | ST_ATRAN: 8301 94 | ST_B10: 45713 95 | ST_CDIST: 48 96 | ST_DRAD: 576 97 | ST_EMIS: 9553 98 | ST_EMSD: 151 99 | ST_QA: 373 100 | ST_TRAD: 9362 101 | ST_URAD: 1110 102 | cloud - cloud: 0 103 | cloud confidence - clouds high prob: 0 104 | cloud confidence - clouds low prob: 1 105 | cloud confidence - clouds medium prob: 0 106 | cloud shadow - cloud shadow: 0 107 | shadow confidence - shadow high prob: 0 108 | shadow confidence - shadow low prob: 1 109 | shadow confidence - shadow medium prob: 0 110 | snow - snow: 0 111 | water - water: 0 112 | type: Feature 113 | - geometry: 114 | coordinates: 115 | - -71.421715 116 | - -43.174217 117 | type: Point 118 | id: '3' 119 | properties: 120 | QA_PIXEL: 21824 121 | QA_RADSAT: 0 122 | SR_B1: 8207 123 | SR_B2: 8474 124 | SR_B3: 9804 125 | SR_B4: 9442 126 | SR_B5: 21391 127 | SR_B6: 13786 128 | SR_B7: 10443 129 | SR_QA_AEROSOL: 160 130 | ST_ATRAN: 8428 131 | ST_B10: 43731 132 | ST_CDIST: 207 133 | ST_DRAD: 527 134 | ST_EMIS: 9676 135 | ST_EMSD: 155 136 | ST_QA: 279 137 | ST_TRAD: 8685 138 | ST_URAD: 1007 139 | cloud - cloud: 0 140 | cloud confidence - clouds high prob: 0 141 | cloud confidence - clouds low prob: 1 142 | cloud confidence - clouds medium prob: 0 143 | cloud shadow - cloud shadow: 0 144 | shadow confidence - shadow high prob: 0 145 | shadow confidence - shadow low prob: 1 146 | shadow confidence - shadow medium prob: 0 147 | snow - snow: 0 148 | water - water: 0 149 | type: Feature 150 | - geometry: 151 | coordinates: 152 | - -71.692487 153 | - -43.095786 154 | type: Point 155 | id: '4' 156 | properties: 157 | QA_PIXEL: 21952 158 | QA_RADSAT: 0 159 | SR_B1: 7660 160 | SR_B2: 7757 161 | SR_B3: 7539 162 | SR_B4: 7137 163 | SR_B5: 7214 164 | SR_B6: 7369 165 | SR_B7: 7419 166 | SR_QA_AEROSOL: 164 167 | ST_ATRAN: 8281 168 | ST_B10: 40731 169 | ST_CDIST: 73 170 | ST_DRAD: 575 171 | ST_EMIS: 9880 172 | ST_EMSD: 0 173 | ST_QA: 355 174 | ST_TRAD: 7675 175 | ST_URAD: 1112 176 | cloud - cloud: 0 177 | cloud confidence - clouds high prob: 0 178 | cloud confidence - clouds low prob: 1 179 | cloud confidence - clouds medium prob: 0 180 | cloud shadow - cloud shadow: 0 181 | shadow confidence - shadow high prob: 0 182 | shadow confidence - shadow low prob: 1 183 | shadow confidence - shadow medium prob: 0 184 | snow - snow: 0 185 | water - water: 1 186 | type: Feature 187 | - geometry: 188 | coordinates: 189 | - -71.717352 190 | - -42.998725 191 | type: Point 192 | id: '5' 193 | properties: 194 | QA_PIXEL: 23888 195 | QA_RADSAT: 0 196 | SR_B1: 7234 197 | SR_B2: 7267 198 | SR_B3: 7409 199 | SR_B4: 7168 200 | SR_B5: 9826 201 | SR_B6: 7890 202 | SR_B7: 7526 203 | SR_QA_AEROSOL: 164 204 | ST_ATRAN: 8313 205 | ST_B10: 40498 206 | ST_CDIST: 33 207 | ST_DRAD: 559 208 | ST_EMIS: 9904 209 | ST_EMSD: 209 210 | ST_QA: 465 211 | ST_TRAD: 7598 212 | ST_URAD: 1079 213 | cloud - cloud: 0 214 | cloud confidence - clouds high prob: 0 215 | cloud confidence - clouds low prob: 1 216 | cloud confidence - clouds medium prob: 0 217 | cloud shadow - cloud shadow: 1 218 | shadow confidence - shadow high prob: 1 219 | shadow confidence - shadow low prob: 0 220 | shadow confidence - shadow medium prob: 0 221 | snow - snow: 0 222 | water - water: 0 223 | type: Feature 224 | - geometry: 225 | coordinates: 226 | - -71.611959 227 | - -43.151053 228 | type: Point 229 | id: '6' 230 | properties: 231 | QA_PIXEL: 22280 232 | QA_RADSAT: 0 233 | SR_B1: 21557 234 | SR_B2: 22022 235 | SR_B3: 22455 236 | SR_B4: 22805 237 | SR_B5: 27873 238 | SR_B6: 23098 239 | SR_B7: 21153 240 | SR_QA_AEROSOL: 96 241 | ST_ATRAN: 8251 242 | ST_B10: 37429 243 | ST_CDIST: 0 244 | ST_DRAD: 589 245 | ST_EMIS: 9785 246 | ST_EMSD: 59 247 | ST_QA: 632 248 | ST_TRAD: 6511 249 | ST_URAD: 1141 250 | cloud - cloud: 1 251 | cloud confidence - clouds high prob: 1 252 | cloud confidence - clouds low prob: 0 253 | cloud confidence - clouds medium prob: 0 254 | cloud shadow - cloud shadow: 0 255 | shadow confidence - shadow high prob: 0 256 | shadow confidence - shadow low prob: 1 257 | shadow confidence - shadow medium prob: 0 258 | snow - snow: 0 259 | water - water: 0 260 | type: Feature 261 | - geometry: 262 | coordinates: 263 | - -71.682779 264 | - -43.178612 265 | type: Point 266 | id: '7' 267 | properties: 268 | QA_PIXEL: 21824 269 | QA_RADSAT: 0 270 | SR_B1: 8454 271 | SR_B2: 8504 272 | SR_B3: 9072 273 | SR_B4: 8647 274 | SR_B5: 15291 275 | SR_B6: 12358 276 | SR_B7: 10203 277 | SR_QA_AEROSOL: 96 278 | ST_ATRAN: 8260 279 | ST_B10: 43558 280 | ST_CDIST: 47 281 | ST_DRAD: 584 282 | ST_EMIS: 9805 283 | ST_EMSD: 68 284 | ST_QA: 376 285 | ST_TRAD: 8685 286 | ST_URAD: 1131 287 | cloud - cloud: 0 288 | cloud confidence - clouds high prob: 0 289 | cloud confidence - clouds low prob: 1 290 | cloud confidence - clouds medium prob: 0 291 | cloud shadow - cloud shadow: 0 292 | shadow confidence - shadow high prob: 0 293 | shadow confidence - shadow low prob: 1 294 | shadow confidence - shadow medium prob: 0 295 | snow - snow: 0 296 | water - water: 0 297 | type: Feature 298 | - geometry: 299 | coordinates: 300 | - -71.639914 301 | - -42.988605 302 | type: Point 303 | id: '8' 304 | properties: 305 | QA_PIXEL: 22280 306 | QA_RADSAT: 0 307 | SR_B1: 7419 308 | SR_B2: 8856 309 | SR_B3: 9832 310 | SR_B4: 10910 311 | SR_B5: 20082 312 | SR_B6: 15667 313 | SR_B7: 14434 314 | SR_QA_AEROSOL: 224 315 | ST_ATRAN: 8670 316 | ST_B10: 36914 317 | ST_CDIST: 0 318 | ST_DRAD: 434 319 | ST_EMIS: 9829 320 | ST_EMSD: 28 321 | ST_QA: 637 322 | ST_TRAD: 6303 323 | ST_URAD: 812 324 | cloud - cloud: 1 325 | cloud confidence - clouds high prob: 1 326 | cloud confidence - clouds low prob: 0 327 | cloud confidence - clouds medium prob: 0 328 | cloud shadow - cloud shadow: 0 329 | shadow confidence - shadow high prob: 0 330 | shadow confidence - shadow low prob: 1 331 | shadow confidence - shadow medium prob: 0 332 | snow - snow: 0 333 | water - water: 0 334 | type: Feature 335 | - geometry: 336 | coordinates: 337 | - -71.599761 338 | - -43.169497 339 | type: Point 340 | id: '9' 341 | properties: 342 | QA_PIXEL: 21824 343 | QA_RADSAT: 0 344 | SR_B1: 7909 345 | SR_B2: 7947 346 | SR_B3: 8659 347 | SR_B4: 8242 348 | SR_B5: 15091 349 | SR_B6: 11310 350 | SR_B7: 9269 351 | SR_QA_AEROSOL: 130 352 | ST_ATRAN: 8228 353 | ST_B10: 43956 354 | ST_CDIST: 76 355 | ST_DRAD: 598 356 | ST_EMIS: 9845 357 | ST_EMSD: 82 358 | ST_QA: 344 359 | ST_TRAD: 8869 360 | ST_URAD: 1161 361 | cloud - cloud: 0 362 | cloud confidence - clouds high prob: 0 363 | cloud confidence - clouds low prob: 1 364 | cloud confidence - clouds medium prob: 0 365 | cloud shadow - cloud shadow: 0 366 | shadow confidence - shadow high prob: 0 367 | shadow confidence - shadow low prob: 1 368 | shadow confidence - shadow medium prob: 0 369 | snow - snow: 0 370 | water - water: 0 371 | type: Feature 372 | type: FeatureCollection 373 | -------------------------------------------------------------------------------- /tests/test_bitmask.py: -------------------------------------------------------------------------------- 1 | """Test the bitmask module.""" 2 | 3 | import ee 4 | import pytest 5 | 6 | from eebit.bitmask import Bit, BitGroup, BitMask 7 | 8 | 9 | class TestBit: 10 | """Test the Bit class.""" 11 | 12 | def test_bit_initialization(self): 13 | """Test the initialization of a Bit object.""" 14 | bit = Bit(position=3, positive="clouds") 15 | assert bit.position == 3 16 | assert bit.positive == "clouds" 17 | assert bit.negative == "no clouds" 18 | assert bit.value_map == {"0": "no clouds", "1": "clouds"} 19 | 20 | def test_bit_min_max_value(self): 21 | """Test the min_value and max_value properties of a Bit object.""" 22 | bit = Bit(position=2, positive="water", negative="land") 23 | assert bit.min_value == int("100", 2) # 4 in decimal 24 | assert bit.max_value == int("111", 2) # 7 in decimal 25 | 26 | def test_is_postive(self): 27 | """Test the is_positive method of a Bit object.""" 28 | bit = Bit(position=2, positive="snow") 29 | assert bit.is_positive(int("100", 2)) # 4 in decimal 30 | assert not bit.is_positive(int("000", 2)) # 0 in decimal 31 | assert not bit.is_positive(int("010", 2)) # 2 in decimal 32 | assert bit.is_positive(int("1001110", 2)) # 78 in decimal 33 | assert not bit.is_positive(int("0001010", 2)) # 10 in decimal 34 | 35 | def test_is_negative(self): 36 | """Test the is_negative method of a Bit object.""" 37 | bit = Bit(position=2, positive="cloudy", negative="clear") 38 | assert bit.is_negative(int("000", 2)) # 0 in decimal 39 | assert not bit.is_negative(int("100", 2)) # 4 in decimal 40 | assert bit.is_negative(int("010", 2)) # 2 in decimal 41 | assert not bit.is_negative(int("111", 2)) # 7 in decimal 42 | assert not bit.is_negative(int("1010100", 2)) # 84 in decimal 43 | assert bit.is_negative(int("1010010", 2)) # 82 in decimal 44 | 45 | 46 | class TestBitGroup: 47 | """Test the BitGroup class.""" 48 | 49 | def test_bitgroup_initialization(self): 50 | """Test the initialization of a BitGroup object.""" 51 | value_map = {0: "no clouds", 1: "low clouds", 2: "medium clouds", 3: "high clouds"} 52 | bit_group = BitGroup( 53 | min_position=1, max_position=2, value_map=value_map, description="Cloud levels" 54 | ) 55 | assert bit_group.min_position == 1 56 | assert bit_group.max_position == 2 57 | assert bit_group.value_map == value_map 58 | assert bit_group.description == "Cloud levels" 59 | 60 | # def test_bitgroup_masks(self): 61 | # """Test the get_mask method of a BitGroup object.""" 62 | # value_map = { 63 | # 0: "no clouds", 64 | # 1: "low clouds", 65 | # 2: "medium clouds", 66 | # 3: "high clouds" 67 | # } 68 | # bit_group = BitGroup(min_position=1, max_position=2, value_map=value_map, description="Cloud levels") 69 | # assert bit_group.get_mask_by_bit_description("no clouds") == int("000", 2) # 0 in decimal 70 | # assert bit_group.get_mask_by_bit_description("low clouds") == int("010", 2) # 2 in decimal 71 | # assert bit_group.get_mask_by_bit_description("medium clouds") == int("100", 2) # 4 in decimal 72 | # assert bit_group.get_mask_by_bit_description("high clouds") == int("110", 2) # 6 in decimal 73 | # with pytest.raises(ValueError): 74 | # bit_group.get_mask_by_bit_description("unknown") 75 | 76 | def test_bitgroup_to_dict(self): 77 | """Test the to_dict method of a BitGroup object.""" 78 | value_map = {0: "no clouds", 1: "low clouds", 2: "medium clouds", 3: "high clouds"} 79 | bit_group = BitGroup( 80 | min_position=1, max_position=2, value_map=value_map, description="Cloud levels" 81 | ) 82 | expected_dict = { 83 | "1-2-Cloud levels": { 84 | "0": "no clouds", 85 | "1": "low clouds", 86 | "2": "medium clouds", 87 | "3": "high clouds", 88 | } 89 | } 90 | assert bit_group.to_dict() == expected_dict 91 | 92 | def test_bitgroup_bit_values(self): 93 | """Test the bit_values property of a BitGroup object.""" 94 | value_map = {1: "low", 2: "medium", 3: "high"} 95 | bit_group = BitGroup( 96 | min_position=1, max_position=2, value_map=value_map, description="cloud level" 97 | ) 98 | expected_values = ["cloud level - low", "cloud level - medium", "cloud level - high"] 99 | assert bit_group.bit_values == expected_values 100 | 101 | def test_bitgroup_is_positive(self): 102 | """Test the is_positive method of a BitGroup object.""" 103 | value_map = {0: "no clouds", 1: "low clouds", 2: "medium clouds", 3: "high clouds"} 104 | bit_group = BitGroup( 105 | min_position=1, max_position=2, value_map=value_map, description="Cloud levels" 106 | ) 107 | assert bit_group.is_positive_by_description( 108 | int("11011001", 2), "no clouds" 109 | ) # 217 in decimal 110 | assert bit_group.is_positive_by_description( 111 | int("11011000", 2), "no clouds" 112 | ) # 216 in decimal 113 | assert bit_group.is_positive_by_description( 114 | int("11011010", 2), "low clouds" 115 | ) # 218 in decimal 116 | assert bit_group.is_positive_by_description( 117 | int("11011011", 2), "low clouds" 118 | ) # 219 in decimal 119 | assert bit_group.is_positive_by_description( 120 | int("11011100", 2), "medium clouds" 121 | ) # 220 in decimal 122 | assert bit_group.is_positive_by_description( 123 | int("11011101", 2), "medium clouds" 124 | ) # 221 in decimal 125 | assert bit_group.is_positive_by_description( 126 | int("11011110", 2), "high clouds" 127 | ) # 222 in decimal 128 | assert bit_group.is_positive_by_description( 129 | int("11011111", 2), "high clouds" 130 | ) # 223 in decimal 131 | assert not bit_group.is_positive_by_description( 132 | int("11011001", 2), "low clouds" 133 | ) # 217 in decimal 134 | assert not bit_group.is_positive_by_description( 135 | int("11011000", 2), "low clouds" 136 | ) # 216 in decimal 137 | assert not bit_group.is_positive_by_description( 138 | int("11011001", 2), "medium clouds" 139 | ) # 217 in decimal 140 | assert not bit_group.is_positive_by_description( 141 | int("11011000", 2), "medium clouds" 142 | ) # 216 in decimal 143 | assert not bit_group.is_positive_by_description( 144 | int("11011001", 2), "high clouds" 145 | ) # 217 in decimal 146 | assert not bit_group.is_positive_by_description( 147 | int("11011000", 2), "high clouds" 148 | ) # 216 in decimal 149 | 150 | def test_is_positive_by_key(self): 151 | """Test the is_positive_by_key method of a BitGroup object.""" 152 | value_map = {0: "no clouds", 1: "low clouds", 2: "medium clouds", 3: "high clouds"} 153 | bit_group = BitGroup( 154 | min_position=1, max_position=2, value_map=value_map, description="Cloud levels" 155 | ) 156 | assert bit_group.is_positive_by_key(int("11011001", 2), "0") # 217 in decimal 157 | assert bit_group.is_positive_by_key(int("11011000", 2), 0) # 216 in decimal 158 | assert bit_group.is_positive_by_key(int("11011010", 2), "1") # 218 in decimal 159 | assert bit_group.is_positive_by_key(int("11011011", 2), 1) # 219 in decimal 160 | assert bit_group.is_positive_by_key(int("11011100", 2), "2") # 220 in decimal 161 | assert bit_group.is_positive_by_key(int("11011101", 2), 2) # 221 in decimal 162 | assert bit_group.is_positive_by_key(int("11011110", 2), "3") # 222 in decimal 163 | assert bit_group.is_positive_by_key(int("11011111", 2), 3) # 223 in decimal 164 | assert not bit_group.is_positive_by_key(int("11011001", 2), "1") # 217 in decimal 165 | assert not bit_group.is_positive_by_key(int("11011000", 2), 1) # 216 in decimal 166 | assert not bit_group.is_positive_by_key(int("11011001", 2), "2") # 217 in decimal 167 | assert not bit_group.is_positive_by_key(int("11011000", 2), 2) # 216 in decimal 168 | assert not bit_group.is_positive_by_key(int("11011001", 2), "3") # 217 in decimal 169 | assert not bit_group.is_positive_by_key(int("11011000", 2), 3) # 216 in decimal 170 | 171 | def test_bitgroup_decode_value(self): 172 | """Test the decode_value method of a BitGroup object.""" 173 | value_map = {0: "no clouds", 1: "low clouds", 2: "medium clouds", 3: "high clouds"} 174 | bit_group = BitGroup( 175 | min_position=1, max_position=2, value_map=value_map, description="Cloud levels" 176 | ) 177 | assert bit_group.decode_value(int("000", 2)) == "no clouds" # 0 in decimal 178 | assert bit_group.decode_value(int("010", 2)) == "low clouds" # 2 in decimal 179 | assert bit_group.decode_value(int("100", 2)) == "medium clouds" # 4 in decimal 180 | assert bit_group.decode_value(int("110", 2)) == "high clouds" # 6 in decimal 181 | 182 | def test_bitgroup_incomplete_decode_value(self): 183 | """Test that BitGroup raises an error for incomplete value_map.""" 184 | incomplete = BitGroup( 185 | min_position=1, 186 | max_position=2, 187 | value_map={0: "no clouds", 2: "medium clouds"}, 188 | description="Cloud levels", 189 | ) 190 | assert incomplete.decode_value(int("000", 2)) == "no clouds" 191 | assert incomplete.decode_value(int("010", 2)) == None 192 | assert incomplete.decode_value(int("100", 2)) == "medium clouds" 193 | assert incomplete.decode_value(int("110", 2)) == None 194 | 195 | def test_bitgroup_get_mask_by_position( 196 | self, aoi_patagonia, cloudy_l8_patagonia, ee_image_regression 197 | ): 198 | """Test the get_mask_by_position method of a BitGroup object.""" 199 | group_dict = {"8-9-Cloud": {"1": "Low", "2": "Medium", "3": "High"}} 200 | bit_group = BitGroup.from_dict(group_dict) 201 | mask = bit_group.get_mask_by_position(cloudy_l8_patagonia.select("QA_PIXEL"), 3) 202 | masked = cloudy_l8_patagonia.updateMask(mask) 203 | vis = {"bands": ["SR_B4", "SR_B3", "SR_B2"], "min": 5000, "max": 20000} 204 | ee_image_regression.check(masked, region=aoi_patagonia, viz_params=vis) 205 | 206 | def test_bitgroup_get_mask_by_bit_value( 207 | self, aoi_patagonia, cloudy_l8_patagonia, ee_image_regression 208 | ): 209 | """Test the get_mask_by_bit_value method of a BitGroup object.""" 210 | group_dict = {"8-9-Cloud": {"1": "Low", "2": "Medium", "3": "High"}} 211 | bit_group = BitGroup.from_dict(group_dict) 212 | mask = bit_group.get_mask_by_bit_value(cloudy_l8_patagonia.select("QA_PIXEL"), "high") 213 | masked = cloudy_l8_patagonia.updateMask(mask) 214 | vis = {"bands": ["SR_B4", "SR_B3", "SR_B2"], "min": 5000, "max": 20000} 215 | ee_image_regression.check(masked, region=aoi_patagonia, viz_params=vis) 216 | 217 | def test_bitgroup_get_masks(self, aoi_patagonia, cloudy_l8_patagonia, ee_image_regression): 218 | """Test the get_masks method of a BitGroup object.""" 219 | group_dict = {"8-9-Cloud": {"1": "Low", "2": "Medium", "3": "High"}} 220 | bit_group = BitGroup.from_dict(group_dict) 221 | masks = bit_group.get_masks(cloudy_l8_patagonia.select("QA_PIXEL")) 222 | vis = {"min": 0, "max": 1} 223 | ee_image_regression.check(masks, region=aoi_patagonia, viz_params=vis) 224 | 225 | def test_bitgroup_decode_to_columns( 226 | self, aoi_patagonia, cloudy_l8_patagonia, ee_feature_collection_regression 227 | ): 228 | """Test the decode_to_columns method of a BitGroup object.""" 229 | group_dict = {"8-9-Cloud": {"1": "Low", "2": "Medium", "3": "High"}} 230 | bit_group = BitGroup.from_dict(group_dict) 231 | points = ee.FeatureCollection.randomPoints(aoi_patagonia, 10) 232 | table = cloudy_l8_patagonia.reduceRegions( 233 | collection=points, reducer=ee.Reducer.first(), scale=30 234 | ) 235 | decoded = bit_group.decode_to_columns(table, "QA_PIXEL") 236 | ee_feature_collection_regression.check(decoded) 237 | 238 | 239 | class TestBitMask: 240 | """Test the BitMask class.""" 241 | 242 | TEST_BITS_OK = { 243 | "1": "shadow", 244 | "2-3-Clouds": { 245 | "0": "no clouds", 246 | "1": "low clouds", 247 | "2": "medium clouds", 248 | "3": "high clouds", 249 | }, 250 | "4-Snow": "snow", 251 | "5-Water": "water", 252 | } 253 | 254 | TEST_BITS_FAIL = { 255 | "1": "shadow", 256 | "2-3-Clouds": { 257 | "0": "no clouds", 258 | "1": "low clouds", 259 | "2": "medium clouds", 260 | "3": "high clouds", 261 | }, 262 | "3-Snow": "snow", # duplicate bit 3 263 | "5-Water": "water", 264 | } 265 | 266 | TEST_BITS_FAIL2 = { 267 | "1": "shadow", 268 | "2-Clouds": { 269 | "0": "no clouds", 270 | }, 271 | } 272 | 273 | TEST_BITS_FAIL3 = {"1": "shadow", "2-3-Clouds": {}} 274 | 275 | TEST_BITS_FAIL4 = { 276 | "1": "shadow", 277 | "2-3-Clouds": {"1": "low clouds", "2": "medium clouds", "4": "high clouds"}, 278 | } 279 | 280 | def test_bitmask_initialization(self): 281 | """Test the initialization of a BitMask object.""" 282 | bitmask = BitMask.from_dict(self.TEST_BITS_OK) 283 | assert isinstance(bitmask, BitMask) 284 | 285 | def test_bitmask_all_bits_duplicate(self): 286 | """Test that BitMask raises an error for duplicate bits.""" 287 | with pytest.raises(ValueError): 288 | BitMask.from_dict(self.TEST_BITS_FAIL) 289 | 290 | def test_bitmask_no_positive(self): 291 | """Test that BitMask raises an error for missing positive values.""" 292 | with pytest.raises(ValueError): 293 | BitMask.from_dict(self.TEST_BITS_FAIL2) 294 | 295 | def test_bitmask_empty_value_map(self): 296 | """Test that BitMask raises an error for empty value map.""" 297 | with pytest.raises(ValueError): 298 | BitMask.from_dict(self.TEST_BITS_FAIL3) 299 | 300 | def test_bitmask_invalid_value_map(self): 301 | """Test that BitMask raises an error for invalid value map.""" 302 | with pytest.raises(ValueError): 303 | BitMask.from_dict(self.TEST_BITS_FAIL4) 304 | 305 | def test_bitmask_to_dict(self): 306 | """Test the to_dict method of a BitMask object.""" 307 | bitmask = BitMask.from_dict(self.TEST_BITS_OK) 308 | expected = { 309 | "1-1-shadow": {"0": "no shadow", "1": "shadow"}, 310 | "2-3-Clouds": { 311 | "0": "no clouds", 312 | "1": "low clouds", 313 | "2": "medium clouds", 314 | "3": "high clouds", 315 | }, 316 | "4-4-Snow": {"0": "no snow", "1": "snow"}, 317 | "5-5-Water": {"0": "no water", "1": "water"}, 318 | } 319 | assert bitmask.to_dict() == expected 320 | 321 | def test_bitmask_get_masks( 322 | self, aoi_patagonia, cloudy_l8_patagonia, l89_qa_bits, ee_image_regression 323 | ): 324 | """Test the get_masks method of a BitMask object.""" 325 | bitmask = BitMask.from_dict(l89_qa_bits) 326 | masks = bitmask.get_masks(cloudy_l8_patagonia.select("QA_PIXEL")) 327 | bands = masks.bandNames().getInfo() 328 | expected_bands = [ 329 | "Cloud - no Cloud", 330 | "Cloud - Cloud", 331 | "Cloud Shadow - no Cloud Shadow", 332 | "Cloud Shadow - Cloud Shadow", 333 | "Snow - no Snow", 334 | "Snow - Snow", 335 | "Water - no Water", 336 | "Water - Water", 337 | "Cloud Confidence - Clouds Low Prob", 338 | "Cloud Confidence - Clouds Medium Prob", 339 | "Cloud Confidence - Clouds High Prob", 340 | "Shadow Confidence - Shadow Low Prob", 341 | "Shadow Confidence - Shadow Medium Prob", 342 | "Shadow Confidence - Shadow High Prob", 343 | ] 344 | assert bands == expected_bands 345 | test_image = masks.select(["Cloud - Cloud", "Cloud Shadow - Cloud Shadow", "Water - Water"]) 346 | vis = {"min": 0, "max": 1} 347 | ee_image_regression.check(test_image, region=aoi_patagonia, viz_params=vis) 348 | 349 | def test_bitmask_decode_value(self): 350 | """Test the decode_value method of a BitMask object.""" 351 | bitmask = BitMask.from_dict(self.TEST_BITS_OK) 352 | assert bitmask.decode_value(int("00000", 2)) == { 353 | "shadow": "no shadow", 354 | "clouds": "no clouds", 355 | "snow": "no snow", 356 | "water": "no water", 357 | } 358 | assert bitmask.decode_value(int("00010", 2)) == { 359 | "shadow": "no shadow", 360 | "Clouds": "low clouds", 361 | "Snow": "no snow", 362 | "Water": "no water", 363 | } 364 | assert bitmask.decode_value(int("00110", 2)) == { 365 | "shadow": "no shadow", 366 | "Clouds": "medium clouds", 367 | "Snow": "snow", 368 | "Water": "no water", 369 | } 370 | assert bitmask.decode_value(int("11111", 2)) == { 371 | "shadow": "shadow", 372 | "Clouds": "high clouds", 373 | "Snow": "snow", 374 | "Water": "water", 375 | } 376 | 377 | def test_bitmask_decode_to_columns( 378 | self, aoi_patagonia, cloudy_l8_patagonia, l89_qa_bits, ee_feature_collection_regression 379 | ): 380 | """Test the decode_to_columns method of a BitMask object.""" 381 | bitmask = BitMask.from_dict(l89_qa_bits) 382 | points = ee.FeatureCollection.randomPoints(aoi_patagonia, 10) 383 | table = cloudy_l8_patagonia.reduceRegions( 384 | collection=points, reducer=ee.Reducer.first(), scale=30 385 | ) 386 | decoded = bitmask.decode_to_columns(table, "QA_PIXEL") 387 | ee_feature_collection_regression.check(decoded) 388 | -------------------------------------------------------------------------------- /eebit/bitmask.py: -------------------------------------------------------------------------------- 1 | """Bitmask module.""" 2 | 3 | from typing import Literal 4 | 5 | import ee 6 | import geetools # noqa: F401 7 | 8 | from eebit import helpers 9 | 10 | 11 | class Bit: 12 | """Class that represents a single bit.""" 13 | 14 | def __init__(self, position: int | str, positive: str, negative: str | None = None): 15 | """Initialize a Bit. 16 | 17 | Args: 18 | position: position of the bit. 19 | positive: positive bit description. 20 | negative: negative bit description. If None, it uses "no {positive}". 21 | """ 22 | if helpers.is_int(position): 23 | self.position = int(position) 24 | else: 25 | raise TypeError("Bit position must be an integer.") 26 | self.positive = positive 27 | self.negative = negative or f"no {positive}" 28 | 29 | @property 30 | def min_value(self) -> int: 31 | """Get the minimum value of the bit.""" 32 | return 1 << self.position 33 | 34 | @property 35 | def max_value(self) -> int: 36 | """Get the maximum value of the bit.""" 37 | return (1 << (self.position + 1)) - 1 38 | 39 | def positive_values(self, n_bits: int | None = None) -> list: 40 | """Get the positive values of the bit. 41 | 42 | Args: 43 | n_bits: number of bits to consider. If None, it uses the bit position + 1. 44 | """ 45 | if n_bits is None: 46 | n_bits = self.position 47 | return [n for n in range(self.min_value, (1 << n_bits + 1)) if self.is_positive(n)] 48 | 49 | def negative_values(self, n_bits: int | None = None) -> list: 50 | """Get the negative values of the bit. 51 | 52 | Args: 53 | n_bits: number of bits to consider. If None, it uses the bit position + 1. 54 | """ 55 | if n_bits is None: 56 | n_bits = self.position 57 | return [n for n in range(0, (1 << n_bits + 1)) if self.is_negative(n)] 58 | 59 | def is_positive(self, value: int) -> bool: 60 | """Check if a value is positive for this bit. 61 | 62 | Args: 63 | value: the value to check. 64 | 65 | Returns: 66 | True if the value is positive for this bit, False otherwise. 67 | """ 68 | if not isinstance(value, int): 69 | raise TypeError("Value must be an integer.") 70 | return (value & self.min_value) != 0 71 | 72 | def is_negative(self, value: int) -> bool: 73 | """Check if a value is negative for this bit. 74 | 75 | Args: 76 | value: the value to check. 77 | 78 | Returns: 79 | True if the value is negative for this bit, False otherwise. 80 | """ 81 | if not isinstance(value, int): 82 | raise TypeError("Value must be an integer.") 83 | return (value & self.min_value) == 0 84 | 85 | @property 86 | def value_map(self) -> dict[Literal["0", "1"], str]: 87 | """Get the value map of the bit.""" 88 | return {"0": self.negative, "1": self.positive} 89 | 90 | def to_bit_group(self, description: str) -> "BitGroup": 91 | """Convert a Bit to a BitGroup. 92 | 93 | Args: 94 | description: description of the bit group. 95 | 96 | Returns: 97 | A BitGroup object. 98 | """ 99 | return BitGroup( 100 | min_position=self.position, 101 | max_position=self.position, 102 | value_map={0: self.negative, 1: self.positive}, 103 | description=description, 104 | ) 105 | 106 | 107 | class BitGroup: 108 | """Class that represents a bit group.""" 109 | 110 | BAND_NAME_PATTERN = "{description} - {value}" 111 | 112 | def __init__(self, description: str, min_position: int, max_position: int, value_map: dict): 113 | """Initialize a bit group. 114 | 115 | Args: 116 | min_position: minimum position of the bit group. 117 | max_position: maximum position of the bit group. 118 | value_map: a dict with the bit positions as keys and the bit descriptions as values. {bit-key: bit-value} 119 | description: description of the bit group. Different from each bit descriptions in the value_map. 120 | """ 121 | description = description.lower() # normalize description to lowercase 122 | # validate min_position and max_position 123 | if not helpers.is_int(min_position) or not helpers.is_int(max_position): 124 | raise TypeError("Bit positions must be integers.") 125 | min_position, max_position = int(min_position), int(max_position) 126 | if min_position < 0 or max_position < 0: 127 | raise ValueError("Bit positions must be non-negative.") 128 | if min_position > max_position: 129 | raise ValueError("Minimum position must be less than or equal to maximum position.") 130 | self.min_position = min_position 131 | self.max_position = max_position 132 | self.description = description 133 | # number of alternative values 134 | self.n_values = 2 ** (max_position - min_position + 1) # 2^n where n is the number of bits 135 | # validate value_map 136 | _value_map = {} 137 | for k, v in value_map.items(): 138 | if not helpers.is_int(k): 139 | raise ValueError(f"Bit position '{k}' must be an integer.") 140 | if not helpers.is_str(v): 141 | raise ValueError(f"Bit value '{v}' must be a non-empty string.") 142 | k_int = int(k) 143 | if k_int in _value_map: 144 | raise ValueError(f"Bit position '{k}' is duplicated in the value map.") 145 | if k_int < 0 or k_int >= self.n_values: 146 | raise ValueError( 147 | f"Bit position '{k}' is out of range for the bit group ({self.n_values} values)." 148 | ) 149 | _value_map[k_int] = v.lower() # normalize value to lowercase 150 | if len(_value_map) == 0: 151 | raise ValueError("Value map cannot be empty.") 152 | if (min_position == max_position) and (len(_value_map) == 1) and (1 not in _value_map): 153 | raise ValueError("For single bit groups, the value map must contain a value for bit 1.") 154 | self.value_map = _value_map 155 | self._reverse_value_map = {v: k for k, v in self.value_map.items()} 156 | 157 | def to_dict(self) -> dict: 158 | """Convert a bit group to a dict.""" 159 | key = f"{self.min_position}-{self.max_position}-{self.description}" 160 | value = {str(k): v for k, v in self.value_map.items()} 161 | return {key: value} 162 | 163 | def _get_key_for_bit_value(self, value: str) -> int: 164 | """Get the key for a given bit value. 165 | 166 | Args: 167 | value: the bit value to get the key for. 168 | """ 169 | key = self._reverse_value_map.get(value) 170 | if key is None: 171 | raise ValueError( 172 | f"Key for value '{value}' not found in the value map '{self.value_map}'." 173 | ) 174 | return key 175 | 176 | def _get_value_for_bit_key(self, key: int) -> str: 177 | """Get the bit value for a given bit key.""" 178 | value = self.value_map.get(key) 179 | if value is None: 180 | raise ValueError(f"Key '{key}' not found in the value map '{self.value_map}'.") 181 | return value 182 | 183 | # def get_mask_by_bit_key(self, key: int | str) -> int: 184 | # """Get the mask for a given key. 185 | # 186 | # Args: 187 | # key: the key to get the mask for. 188 | # 189 | # Returns: 190 | # The mask for the given key. 191 | # """ 192 | # if not helpers.is_int(key): 193 | # raise TypeError("Bit key must be an integer.") 194 | # key = int(key) 195 | # if key < 0 or key >= self.n_values: 196 | # raise ValueError(f"Bit key '{key}' is out of range for the bit group ({self.n_values} values).") 197 | # # shift the key to the left by min_position 198 | # return key << self.min_position 199 | # 200 | # def get_mask_by_bit_description(self, description: str) -> int: 201 | # """Get the mask for a given description. 202 | # 203 | # Args: 204 | # description: the description to get the mask for. 205 | # 206 | # Returns: 207 | # The mask for the given description. 208 | # """ 209 | # key = self._get_key_for_bit_description(description) 210 | # # shift the key to the left by min_position 211 | # return self.get_mask_by_bit_key(key) 212 | 213 | @property 214 | def group_mask(self) -> int: 215 | """Get the mask for the entire group.""" 216 | num_bits = self.max_position - self.min_position + 1 217 | return (1 << num_bits) - 1 218 | 219 | def decode_value(self, value: int) -> str | None: 220 | """Decode a value into its description. 221 | 222 | Args: 223 | value: the value to decode. 224 | 225 | Returns: 226 | The description of the value, or None if not found. 227 | """ 228 | group_value = (value >> self.min_position) & self.group_mask 229 | return self.value_map.get(group_value) 230 | 231 | def is_positive_by_key(self, value: int, key: int | str) -> bool: 232 | """Check if a value is positive for this key. 233 | 234 | Args: 235 | value: the value to check. 236 | key: the key to check. 237 | 238 | Returns: 239 | True if the value is positive for the passed key, False otherwise. 240 | """ 241 | if not helpers.is_int(key): 242 | raise TypeError("Bit key must be an integer.") 243 | key = int(key) 244 | # if key < 0 or key >= self.n_values: 245 | if key not in self.value_map: 246 | raise ValueError( 247 | f"Bit key '{key}' is out of range for the bit group ({self.n_values} values)." 248 | ) 249 | actual_group_value = (value >> self.min_position) & self.group_mask 250 | return actual_group_value == key 251 | 252 | def is_positive_by_key_gee(self, value: ee.Number, key: int | str) -> bool: 253 | """Check if a value is positive for this key using GEE. 254 | 255 | Args: 256 | value: the value to check. 257 | key: the key to check. 258 | 259 | Returns: 260 | True if the value is positive for the passed key, False otherwise. 261 | """ 262 | if not helpers.is_int(key): 263 | raise TypeError("Bit key must be an integer.") 264 | key = int(key) 265 | # if key < 0 or key >= self.n_values: 266 | if key not in self.value_map: 267 | raise ValueError( 268 | f"Bit key '{key}' is out of range for the bit group ({self.n_values} values)." 269 | ) 270 | actual_group_value = value.rightShift(self.min_position).bitwiseAnd(self.group_mask) 271 | return actual_group_value.eq(key) 272 | 273 | def is_positive_by_description(self, value: int, description: str) -> bool: 274 | """Check if a value is positive for this description. 275 | 276 | Args: 277 | value: the value to check. 278 | description: the description to check. 279 | 280 | Returns: 281 | True if the value is positive for the passed description, False otherwise. 282 | """ 283 | ## Alternative implementation using get_mask 284 | # use get_mask to get the mask for the description 285 | # mask = self.get_mask(description) 286 | # rest = value >> (self.max_position + 1) << (self.max_position + 1) 287 | # value = (value - rest) >> self.min_position << self.min_position 288 | # return value == mask 289 | expected_group_value = self._get_key_for_bit_value(description) 290 | return self.is_positive_by_key(value, expected_group_value) 291 | 292 | def is_positive_by_description_gee(self, value: ee.Number, description: str) -> bool: 293 | """Check if a value is positive for this description using GEE. 294 | 295 | Args: 296 | value: the value to check. 297 | description: the description to check. 298 | 299 | Returns: 300 | True if the value is positive for the passed description, False otherwise. 301 | """ 302 | expected_group_value = self._get_key_for_bit_value(description) 303 | return self.is_positive_by_key_gee(value, expected_group_value) 304 | 305 | def is_positive( 306 | self, value: int, key: int | str | None = None, description: str | None = None 307 | ) -> bool: 308 | """Check if a value is positive for this bit group. 309 | 310 | Args: 311 | value: the value to check. 312 | key: the key to check. 313 | description: the description to check. 314 | 315 | Returns: 316 | True if the value is positive for the passed key or description, False otherwise. 317 | """ 318 | if key is not None and description is not None: 319 | raise ValueError("Only one of key or description should be provided.") 320 | if key is not None: 321 | return self.is_positive_by_key(value, key) 322 | elif description is not None: 323 | return self.is_positive_by_description(value, description) 324 | else: 325 | raise ValueError("Either key or description must be provided.") 326 | 327 | def is_positive_gee( 328 | self, value: ee.Number, key: int | str | None = None, description: str | None = None 329 | ) -> bool: 330 | """Check if a value is positive for this bit group using GEE. 331 | 332 | Args: 333 | value: the value to check. 334 | key: the key to check. 335 | description: the description to check. 336 | 337 | Returns: 338 | True if the value is positive for the passed key or description, False otherwise. 339 | """ 340 | if key is not None and description is not None: 341 | raise ValueError("Only one of key or description should be provided.") 342 | if key is not None: 343 | return self.is_positive_by_key_gee(value, key) 344 | elif description is not None: 345 | return self.is_positive_by_description_gee(value, description) 346 | else: 347 | raise ValueError("Either key or description must be provided.") 348 | 349 | @property 350 | def bit_values(self) -> list[str]: 351 | """Get the list of bit values in the value map.""" 352 | if self.min_position == self.max_position: 353 | if len(self.value_map) == 1: 354 | l = [self.description] 355 | else: 356 | if self.description == self.value_map[1]: 357 | l = [self.description, self.value_map[0]] 358 | else: 359 | l = [self.value_map[0], self.description] 360 | else: 361 | l = [ 362 | self.BAND_NAME_PATTERN.format(description=self.description, value=v) 363 | for v in self.value_map.values() 364 | ] 365 | return l 366 | 367 | def get_mask_by_position(self, image: ee.Image, position: int | str) -> ee.Image: 368 | """Get a mask for a given bit position in the group. 369 | 370 | Args: 371 | image: the image to get the mask from. 372 | position: the position of the bit in the group. 373 | 374 | Returns: 375 | A binary image with 1 for pixels that have the bit set, and 0 otherwise. 376 | """ 377 | if not helpers.is_int(position): 378 | raise TypeError("Bit position must be an integer.") 379 | position = int(position) 380 | decoded = image.rightShift(self.min_position).bitwiseAnd(self.group_mask) 381 | bname = self.BAND_NAME_PATTERN.format( 382 | description=self.description, value=self._get_value_for_bit_key(position) 383 | ) 384 | return decoded.eq(position).rename(bname) 385 | 386 | def get_mask_by_bit_value(self, image: ee.Image, value: str) -> ee.Image: 387 | """Get a mask for a given bit value in the group. 388 | 389 | Args: 390 | image: the image to get the mask from. 391 | value: the bit value to get the mask for. 392 | 393 | Returns: 394 | A binary image with 1 for pixels that have the description, and 0 otherwise. 395 | """ 396 | key = self._get_key_for_bit_value(value) 397 | # shift the image to the right by min_position 398 | shifted = image.rightShift(self.min_position) 399 | # get the bit at the given position 400 | bit = shifted.bitwiseAnd(self.group_mask) 401 | # return a binary image 402 | bname = self.BAND_NAME_PATTERN.format(description=self.description, value=value) 403 | return bit.eq(key).rename(bname) 404 | 405 | def get_masks(self, image: ee.Image) -> ee.Image: 406 | """Get masks for all bit values in the group. 407 | 408 | Args: 409 | image: the image to get the masks from. 410 | 411 | Returns: 412 | An image with one band per bit value in the group. 413 | """ 414 | masks = [] 415 | for key, value in self.value_map.items(): 416 | mask = self.get_mask_by_bit_value(image, value) 417 | masks.append(mask) 418 | return ee.Image.geetools.fromList(masks) 419 | 420 | def decode_to_columns(self, table: ee.FeatureCollection, column: str) -> ee.FeatureCollection: 421 | """Decode a column in a FeatureCollection into multiple columns. 422 | 423 | Args: 424 | table: the FeatureCollection to decode. 425 | column: the column to decode. 426 | 427 | Returns: 428 | A new FeatureCollection with one column per bit value in the group. 429 | """ 430 | for key, value in self.value_map.items(): 431 | column_name = self.BAND_NAME_PATTERN.format(description=self.description, value=value) 432 | 433 | def set_bit_value(f: ee.Feature) -> ee.Feature: 434 | v = f.get(column) 435 | is_pos = self.is_positive_by_key_gee(ee.Number(v), key) 436 | return f.set(column_name, is_pos) 437 | 438 | table = table.map(set_bit_value) 439 | return table 440 | 441 | @classmethod 442 | def from_dict(cls, bit_info: dict) -> "BitGroup": 443 | """Create a BitGroup from a dict. 444 | 445 | Args: 446 | bit_info: a dict with the bit positions as keys and the bit descriptions as values. 447 | 448 | Returns: 449 | A BitGroup object. 450 | """ 451 | if len(bit_info) != 1: 452 | raise ValueError("Bit info must contain exactly one entry.") 453 | key = list(bit_info.keys())[0] 454 | value = bit_info[key] 455 | start, end, description = key.split("-", 2) 456 | return cls( 457 | min_position=int(start), 458 | max_position=int(end), 459 | value_map={int(k): v for k, v in value.items()}, 460 | description=description, 461 | ) 462 | 463 | 464 | class BitMask: 465 | """Class that represents a bit mask in a BitBand.""" 466 | 467 | def __init__(self, bits: list[BitGroup], total: int | None = None): 468 | """Initialize a bitmask. 469 | 470 | Args: 471 | bits: a list of BitGroup. 472 | total: total number of bits. If None, it uses the maximum position of the last group + 1. 473 | """ 474 | _bits = [] 475 | _descriptions = [] 476 | for bit in bits: 477 | group = bit.to_bit_group(description=bit.positive) if isinstance(bit, Bit) else bit 478 | if not isinstance(group, BitGroup): 479 | raise TypeError("Bits must be a list of Bit or BitGroup.") 480 | if group.description in _descriptions: 481 | raise ValueError( 482 | f"Bit description '{group.description}' is duplicated in the bitmask." 483 | ) 484 | _descriptions.append(group.description) 485 | _bits.append(group) 486 | # check for overlapping bits 487 | all_positions = [] 488 | for group in _bits: 489 | positions = list(range(group.min_position, group.max_position + 1)) 490 | for pos in positions: 491 | if pos in all_positions: 492 | raise ValueError(f"Bit position {pos} is duplicated in the bitmask.") 493 | all_positions.append(pos) 494 | self.bits = _bits 495 | self.total = total or (self.bits[-1].max_position + 1) 496 | 497 | def to_dict(self) -> dict: 498 | """Convert a Bitmask into a dict.""" 499 | final = {} 500 | for group in self.bits: 501 | final.update(group.to_dict()) 502 | return final 503 | 504 | @classmethod 505 | def from_dict(cls, bits_info: dict) -> "BitMask": 506 | """Create a BitMask from a dict.""" 507 | formatted = helpers.format_bits_info(bits_info) 508 | groups = [] 509 | for key, value in formatted.items(): 510 | start, end, description = key.split("-", 2) 511 | group = BitGroup( 512 | min_position=int(start), 513 | max_position=int(end), 514 | value_map={int(k): v for k, v in value.items()}, 515 | description=description, 516 | ) 517 | groups.append(group) 518 | return cls(bits=groups) 519 | 520 | def get_group_by_description(self, description: str) -> BitGroup: 521 | """Get the BitGroup that match a given description. 522 | 523 | Args: 524 | description: the description to search for. 525 | 526 | Returns: 527 | The BitGroup that match the given description. 528 | """ 529 | bits = [bit for bit in self.bits if bit.description == description] 530 | if len(bits) == 0: 531 | raise ValueError(f"Description '{description}' not found in the bitmask.") 532 | return bits[0] 533 | 534 | def decode_value(self, value: int) -> dict[str, str | None]: 535 | """Decode a value into its descriptions. 536 | 537 | Args: 538 | value: the value to decode. 539 | 540 | Returns: 541 | A dict with the descriptions of the value, or None if not found. 542 | """ 543 | decoded = {} 544 | for group in self.bits: 545 | decoded[group.description] = group.decode_value(value) 546 | return decoded 547 | 548 | def bit_values(self) -> list[str]: 549 | """Get the list of bit values in the bitmask.""" 550 | values = [] 551 | for group in self.bits: 552 | values.extend(group.bit_values) 553 | return values 554 | 555 | def get_masks(self, image: ee.Image) -> ee.Image: 556 | """Get masks for all bit values in the bitmask. 557 | 558 | Returns: 559 | An image with one band per bit value in the bitmask. 560 | """ 561 | masks = [] 562 | for group in self.bits: 563 | group_masks = group.get_masks(image) 564 | masks.append(group_masks) 565 | return ee.Image.geetools.fromList(masks) 566 | 567 | def decode_to_columns(self, table: ee.FeatureCollection, column: str) -> ee.FeatureCollection: 568 | """Decode a column in a FeatureCollection into multiple columns. 569 | 570 | Args: 571 | table: the FeatureCollection to decode. 572 | column: the column to decode. 573 | 574 | Returns: 575 | A new FeatureCollection with one column per bit value in the bitmask. 576 | """ 577 | for group in self.bits: 578 | table = group.decode_to_columns(table, column) 579 | return table 580 | --------------------------------------------------------------------------------