diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8439b76 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,314 @@ +name: release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+a[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+b[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+rc[0-9]+" + +env: + PACKAGE_NAME: "wlts.py" + MODULE_NAME: "wlts" + OWNER: "brazil-data-cube" + +jobs: + # Extract tag details + details: + runs-on: ubuntu-latest + outputs: + new_version: ${{ steps.release.outputs.new_version }} + suffix: ${{ steps.release.outputs.suffix }} + tag_name: ${{ steps.release.outputs.tag_name }} + clean_version: ${{ steps.clean_version.outputs.clean_version }} + is_prerelease: ${{ steps.prerelease.outputs.is_prerelease }} + + steps: + - uses: actions/checkout@v4 + + - name: Extract tag and Details + id: release + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + TAG_NAME=${GITHUB_REF#refs/tags/} + NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}') + SUFFIX=$(echo "$NEW_VERSION" | grep -oP '(a|b|rc)[0-9]+' | tr -d '\n' || true) + echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "suffix=${SUFFIX:-}" >> "$GITHUB_OUTPUT" + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + else + echo "No tag found" + exit 1 + fi + + - name: Extract clean version (remove 'v') + id: clean_version + run: | + RAW="${{ steps.release.outputs.new_version }}" + CLEAN="${RAW#v}" + echo "clean_version=$CLEAN" >> $GITHUB_OUTPUT + + - name: Detect prerelease + id: prerelease + run: | + TAG="${{ steps.release.outputs.tag_name }}" + if [[ "$TAG" =~ (a[0-9]+|b[0-9]+|rc[0-9]+)$ ]]; then + echo "is_prerelease=true" >> $GITHUB_OUTPUT + else + echo "is_prerelease=false" >> $GITHUB_OUTPUT + fi + + # Detect branch and updated version + detect-branch: + needs: details + runs-on: ubuntu-latest + outputs: + branch: ${{ steps.detect.outputs.branch }} + version: ${{ steps.version.outputs.clean }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect branch for tag + id: detect + run: | + BRANCH=$(git branch -r --contains "$GITHUB_SHA" | head -n 1 | sed 's/origin\///' | tr -d ' ') + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + + - name: Extract version (remove leading v) + id: version + run: | + CLEAN="${GITHUB_REF#refs/tags/v}" + echo "clean=$CLEAN" >> $GITHUB_OUTPUT + + bump-version: + needs: detect-branch + runs-on: ubuntu-latest + permissions: + contents: write + env: + BRANCH: ${{ needs.detect-branch.outputs.branch }} + VERSION: ${{ needs.detect-branch.outputs.version }} + + steps: + - name: Checkout correct branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ env.BRANCH }} + + - name: Update pyproject.toml version + run: | + sed -i "s/^version = .*/version = \"${VERSION}\"/" pyproject.toml + + - name: Validate pyproject.toml + run: | + pip install validate-pyproject + validate-pyproject pyproject.toml + + - name: Commit version bump + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git add pyproject.toml + + if git diff --cached --quiet; then + echo "No version changes detected. Skipping commit." + else + git commit -m "Release ${VERSION}: update version" + git push origin HEAD:${BRANCH} + fi + + - name: Upload updated pyproject + uses: actions/upload-artifact@v4 + with: + name: updated-project + path: | + pyproject.toml + + # Check version on Pypi + check_pypi: + needs: details + runs-on: ubuntu-latest + + steps: + - name: Fetch information from PyPI + run: | + response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}") + latest_previous_version=$(echo $response | grep -oP '"releases":\{"\K[^"]+' | sort -rV | head -n 1) + if [ -z "$latest_previous_version" ]; then + echo "Package not found on PyPI." + latest_previous_version="0.0.0" + fi + echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV + + - name: Compare versions and exit if not newer + run: | + NEW_VERSION=${{ needs.details.outputs.new_version }} + LATEST_VERSION=$latest_previous_version + if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] \ + || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then + echo "The new version $NEW_VERSION is not greater than $LATEST_VERSION" + exit 1 + fi + + # Build Package + setup_and_build: + needs: [details, check_pypi, bump-version] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: updated-project + path: . + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build and twine + run: | + python3 -m pip install --upgrade pip + python3 -m pip install --upgrade "setuptools>=67" wheel build twine + + - name: Clean previous artifacts + run: | + rm -rf dist *.egg-info + + - name: Generate stubs + run: | + python3 -m pip install mypy + stubgen ${{ env.MODULE_NAME }} -o . + + - name: Build source and wheel distribution + run: | + python3 -m build + + - name: Check distributions (fail fast) + run: | + python3 -m twine check dist/* + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + # Publish in TestPyPI + testpypi_publish: + name: Upload to TestPyPI (SAFE TEST) + needs: [setup_and_build, details] + runs-on: ubuntu-latest + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Upload to TestPyPI using twine + env: + TWINE_USERNAME: "__token__" + TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} + run: | + python3 -m pip install --upgrade pip + python3 -m pip install twine + python3 -m twine upload \ + --repository-url https://test.pypi.org/legacy/ \ + --verbose \ + dist/* + + # Publish to PYPI + pypi_publish: + name: Upload release to PyPI + needs: [testpypi_publish, setup_and_build, details] + if: ${{ needs.details.outputs.is_prerelease == 'false' }} + runs-on: ubuntu-latest + environment: + name: release + permissions: + id-token: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + # Pre-Release and Release jobs + github_prerelease: + name: Create GitHub Pre-Release + runs-on: ubuntu-latest + needs: + - details + - setup_and_build + - testpypi_publish + + if: > + needs.testpypi_publish.result == 'success' && + needs.details.outputs.is_prerelease == 'true' + + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create GitHub Pre-Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.details.outputs.tag_name }} + name: "Version ${{ needs.details.outputs.clean_version }}" + generate_release_notes: true + prerelease: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + github_release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: + - details + - setup_and_build + - testpypi_publish + - pypi_publish + + if: > + needs.testpypi_publish.result == 'success' && + needs.pypi_publish.result == 'success' && + needs.details.outputs.is_prerelease == 'false' + + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.details.outputs.tag_name }} + name: "Version ${{ needs.details.outputs.clean_version }}" + generate_release_notes: true + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGES.rst b/CHANGES.rst index 0061ae6..3fabefa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -20,6 +20,13 @@ Changes ======= +Version 1.4.0 (2025-12-22) +-------------------------- + +- Include support to Allen's interval temporal logic relationships with point trajectories (`#104 `_) +- Add config for release and publish pypi using actions (`#106 `_) + + Version 1.3.1 (2025-09-03) -------------------------- diff --git a/README.rst b/README.rst index 43a3cd0..17a9964 100644 --- a/README.rst +++ b/README.rst @@ -28,8 +28,9 @@ Python Client Library for Web Land Trajectory Service :target: https://wlts.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status -.. .. image:: https://img.shields.io/badge/pypi-v0.1.0-informational - :target: https://pypi.org/pypi/wlts.py +.. image:: https://img.shields.io/pypi/v/wlts.py + :target: https://pypi.org/project/wlts.py/ + :alt: PyPI version .. image:: https://img.shields.io/badge/lifecycle-maturing-blue.svg :target: https://www.tidyverse.org/lifecycle/#maturing @@ -68,17 +69,24 @@ If you want to know more about WLTS service, please, take a look at its `specifi Installation ============ -See `INSTALL.rst <./INSTALL.rst>`_. +.. code-block:: bash + + pip install wlts.py + +Development Installation +======================== + +See `INSTALL.rst `_. Using WLTS in the Command Line ============================== -See `CLI.rst <./CLI.rst>`_. +See `CLI.rst `_. -Developer Documentation -======================= +Full documentation +================== See https://wlts.readthedocs.io/en/latest. diff --git a/examples/ex-01.py b/examples/ex-01.py index a76fe46..0aa1a35 100644 --- a/examples/ex-01.py +++ b/examples/ex-01.py @@ -30,5 +30,7 @@ tj = service.tj( latitude=-12.0, longitude=-54.0, - collections='mapbiomas-v9' + collections='mapbiomas-v10' ) + +print(tj.df()) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8cd7d34..7f40bad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "wlts.py" -version="1.3.1" +version="1.4.0" description = "." readme = "README.rst" requires-python = ">=3.8" -license = {file = "LICENSE"} +license = { text = "GNU General Public License v3.0" } authors = [ {name = "Brazil Data Cube Team", email = "bdc.team@inpe.br"}, ] diff --git a/setup.cfg b/setup.cfg index e1e9794..1134f21 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,3 +29,6 @@ add_ignore = D401 [options.package_data] tests.jsons = *.json + +[metadata] +license_files = diff --git a/wlts/allen.py b/wlts/allen.py new file mode 100644 index 0000000..4866636 --- /dev/null +++ b/wlts/allen.py @@ -0,0 +1,135 @@ +# +# This file is part of Python Client Library for the WLTS. +# Copyright (C) 2025 INPE. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +"""Python Client Library for WLTS. + +This module introduces a class named ``WLTS`` that can be used to retrieve +trajectories for a given location. +""" + +import pandas as pd + + +def before_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """The function represets a < b.""" + if (a["date"].max() < b["date"].min()): + return a + return pd.DataFrame(columns=a.columns) + + +def after_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """The function represets a > b.""" + if (a["date"].min() > b["date"].max()): + return a + return pd.DataFrame(columns=a.columns) + + +def equals_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """The function represets a == b.""" + if set(a["date"]) & set(b["date"]): + return a[a["date"].isin(b["date"])] + return pd.DataFrame(columns=a.columns) + +def meets_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """Represents a meets b (a == b - 1).""" + if any(a["date"].isin(b["date"] - pd.Timedelta(days=1))): + return a + return pd.DataFrame(columns=a.columns) + + +def met_by_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """Represents a met_by b (a == b + 1).""" + if any(a["date"].isin(b["date"] + pd.Timedelta(days=1))): + return a + return pd.DataFrame(columns=a.columns) + + +def overlaps_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """Represents a overlaps b (a <= b & a + 1 >= b).""" + if any((a["date"].min() <= b["date"]) & ((a["date"].max() + pd.Timedelta(days=1)) >= b["date"])): + return a + return pd.DataFrame(columns=a.columns) + + +def overlapped_by_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """Represents a overlapped_by b (a <= b & a + 1 <= b).""" + if any((a["date"].min() <= b["date"]) & ((a["date"].max() + pd.Timedelta(days=1)) <= b["date"])): + return a + return pd.DataFrame(columns=a.columns) + + +def during_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """Represents a during b (a >= b & a + 1 <= b).""" + if (a["date"].min() >= b["date"].min()) and ((a["date"].max() + pd.Timedelta(days=1)) <= b["date"].max()): + return a + return pd.DataFrame(columns=a.columns) + + +def contains_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """Represents a contains b (a <= b & a + 1 >= b).""" + if (a["date"].min() <= b["date"].min()) and ((a["date"].max() + pd.Timedelta(days=1)) >= b["date"].max()): + return a + return pd.DataFrame(columns=a.columns) + + +def starts_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """Represents a starts b (a == b & a + 1 <= b).""" + if (a["date"].min() == b["date"].min()) and ((a["date"].max() + pd.Timedelta(days=1)) <= b["date"].max()): + return a + return pd.DataFrame(columns=a.columns) + + +def started_by_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """Represents a started_by b (a == b & a + 1 >= b).""" + if (a["date"].min() == b["date"].min()) and ((a["date"].max() + pd.Timedelta(days=1)) >= b["date"].max()): + return a + return pd.DataFrame(columns=a.columns) + + +def finishes_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """Represents a finishes b (a <= b & a + 1 == b).""" + if (a["date"].min() <= b["date"].min()) and ((a["date"].max() + pd.Timedelta(days=1)) == b["date"].max()): + return a + return pd.DataFrame(columns=a.columns) + + +def finished_by_relation(a: pd.DataFrame, b: pd.DataFrame) -> pd.DataFrame: + """Represents a finished_by b (a <= b & a + 1 == b).""" + if (a["date"].min() <= b["date"].min()) and ((a["date"].max() + pd.Timedelta(days=1)) == b["date"].max()): + return a + return pd.DataFrame(columns=a.columns) + + + +# TODO: add other relations (RECUR, CONVERT and EVOLVE) +ALLEN_RELATIONS = { + "before": before_relation, + "after": after_relation, + "equals": equals_relation, + "meets": meets_relation, + "met_by": met_by_relation, + "overlaps": overlaps_relation, + "overlapped_by": overlapped_by_relation, + "during": during_relation, + "contains": contains_relation, + "starts": starts_relation, + "started_by": started_by_relation, + "finishes": finishes_relation, + "finished_by": finished_by_relation, +} + + diff --git a/wlts/wlts.py b/wlts/wlts.py index 5139b06..41317a9 100644 --- a/wlts/wlts.py +++ b/wlts/wlts.py @@ -21,13 +21,15 @@ trajectories for a given location. """ import json -from typing import Dict +from typing import Dict, List, Optional import httpx import lccs import numpy as np +import pandas as pd import requests +from .allen import ALLEN_RELATIONS from .collection import Collections from .trajectories import Trajectories from .trajectory import Trajectory @@ -384,7 +386,7 @@ def plot(self, dataframe, **parameters): collections=list(df["collection"].unique()) ) - # --- Scatter --- + # --- Scatter --- # if parameters["type"] == "scatter": if len(df.point_id.unique()) == 1: df["label"] = ( @@ -424,7 +426,7 @@ def plot(self, dataframe, **parameters): "The scatter plot is for one point only! Please try another type: bar plot." ) - # --- Bar (uma coleção) --- + # --- Bar --- # if parameters["type"] == "bar": if len(df.collection.unique()) == 1 and len(df.point_id.unique()) >= 1: df_group = df.groupby(["date", "class"]).count()["point_id"].unstack() @@ -456,7 +458,7 @@ def plot(self, dataframe, **parameters): ) return fig - # --- Bar (várias coleções) --- + # --- Bar --- # elif len(df.collection.unique()) >= 1 and len(df.point_id.unique()) >= 1: df_group = ( df.groupby(["collection", "date", "class"], observed=False) @@ -566,3 +568,93 @@ def _get(self, url, op, **params): raise ValueError(f"HTTP Response is not JSON: Content-Type: {content_type}") return response.json() + + @staticmethod + def temporal_filter( + df: pd.DataFrame, + target_classes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + relation_op: str = "contains", + ) -> pd.DataFrame: + """ + Filter a WLTS trajectory dataframe based on target classes and time range. + + Parameters + ---------- + df : pd.DataFrame + WLTS trajectory with columns: ["class", "collection", "date", "point_id"]. + target_classes : List[str] + Land use/cover classes of interest. + start_date : str, optional + Start date (YYYY or YYYY-MM-DD). + end_date : str, optional + End date (YYYY or YYYY-MM-DD). + relation_op : str, optional + Relationship operator: "contains" or "equals". + + Returns + ------- + pd.DataFrame + Filtered trajectory dataframe. + """ + if not {"class", "collection", "date", "point_id"}.issubset(df.columns): + raise ValueError("Input dataframe must have columns: class, collection, date, point_id") + + if start_date is None: + start_date = df["date"].min() + if end_date is None: + end_date = df["date"].max() + + # Convert date column to datetime or int + if not pd.api.types.is_datetime64_any_dtype(df["date"]): + df["date"] = pd.to_datetime(df["date"], format="%Y", errors="coerce") + + start_date = pd.to_datetime(start_date, errors="coerce") + end_date = pd.to_datetime(end_date, errors="coerce") + + traj = df[(df["date"] >= start_date) & (df["date"] <= end_date)].copy() + + + traj.loc[~traj["class"].isin(target_classes), "class"] = pd.NA + + def op_fn(x): + if relation_op == "equals": + return x.notna().all() + else: # contains + return x.notna().any() + + mask = ( + traj.groupby("point_id") + .filter(lambda g: op_fn(g["class"])) + .dropna(subset=["class"]) + ) + + return mask + + @staticmethod + def temporal_relation( + a: pd.DataFrame, + b: pd.DataFrame, + temp_fn: str = "before", + ) -> pd.DataFrame: + """Allen Relations.""" + fn = ALLEN_RELATIONS.get(temp_fn) + if fn is None: + raise ValueError(f"Invalid relation '{temp_fn}'. Options: {list(ALLEN_RELATIONS)}") + + all_ids = set(a["point_id"]) & set(b["point_id"]) + a = a[a["point_id"].isin(all_ids)] + b = b[b["point_id"].isin(all_ids)] + + results = [] + for pid in all_ids: + a_id = a[a["point_id"] == pid] + b_id = b[b["point_id"] == pid] + res = fn(a_id, b_id) + if not res.empty: + results.append(res) + + if results: + return pd.concat(results).sort_values(["point_id", "date"]) + return pd.DataFrame(columns=a.columns)