├── 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 |
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 |
--------------------------------------------------------------------------------