Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
852483b
Phase 0: stabilize package and make it pip-installable
JavierLopatin May 25, 2026
b2f1b0f
Phase 1: packaging, tests, CI and docs
JavierLopatin May 25, 2026
0b36638
Phase 2 (reconstruction): composable smoothing/fitting registry
JavierLopatin May 25, 2026
2a420d8
Phase 2 (extraction): composable SOS/EOS registry
JavierLopatin May 25, 2026
31f6ee6
Phase 2 (wiring + trends): complete the composable pipeline
JavierLopatin May 26, 2026
9de4d5c
Phase 3 (performance): apply_ufunc refactor + out-of-core
JavierLopatin May 26, 2026
6c1b33f
Phase 4 (numba): GIL-releasing fast path for linear reconstruction
JavierLopatin May 26, 2026
acf2ce9
Phase 4 (anomalies): non-parametric phenological anomaly detection
JavierLopatin May 26, 2026
1223789
Phase 4 (multi-season): NOS detection via peak finding
JavierLopatin May 26, 2026
a9ff4f6
Phase 4 (uncertainty): bootstrap per-metric uncertainty
JavierLopatin May 26, 2026
84974dc
docs: add executed full-API tutorial notebook
JavierLopatin May 26, 2026
947a0fe
docs: clarify the Southern-Hemisphere section in the tutorial
JavierLopatin May 26, 2026
2702384
PhenoPlot: Southern-Hemisphere axis, outside-left legend, palette for…
JavierLopatin May 26, 2026
b6b4e99
fix(plotting): keep data-driven x-limits in PhenoPlot's southern relabel
JavierLopatin May 26, 2026
220c9a6
docs(tutorial): expand per-function explanations in the API tutorial
JavierLopatin May 26, 2026
fb11924
feat(qa): source-agnostic QA -> weight decoder (phenopy.qa)
JavierLopatin May 26, 2026
0478fd0
docs(examples): Earth Engine QA example + display_map pyproj fix
JavierLopatin May 26, 2026
70b1ff4
feat(reconstruction): weighted reconstruction — QA-weighted Whittaker…
JavierLopatin May 26, 2026
6b89cfe
docs(examples): wire GEE notebook §6 to the weighted reconstruction
JavierLopatin May 26, 2026
0ec8809
fix(rmse): avoid deprecated .chunk(None) in segmented RMSE
JavierLopatin May 26, 2026
a85a809
feat(metrics): add trough (base) and middle-of-season (MOS) LSP metrics
JavierLopatin May 26, 2026
646c2e0
docs(tutorial): refresh for the 18 LSP metrics (trough, mos)
JavierLopatin May 26, 2026
9c0ec1d
perf(bench+docs): benchmark PhenoLSP scaling and rewrite the performa…
JavierLopatin May 26, 2026
4d92c40
refactor!: rename package PhenoPY -> PhenoSensing
JavierLopatin May 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: CI

on:
push:
branches: [master, library-overhaul]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: test (py${{ matrix.python-version }}, ${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[fit,test]"
- name: Lint
run: |
ruff check .
ruff format --check .
- name: Test
run: pytest -q --cov=phenosensing --cov-report=term-missing

build:
name: build distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Build & check
run: |
python -m pip install --upgrade pip build twine
python -m build
python -m twine check dist/*
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
site/

# Notebooks / tooling caches
.ipynb_checkpoints/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/

# Locally cached Earth Engine downloads (see examples/earthengine_*.ipynb)
examples/data/

# OS
.DS_Store
96 changes: 69 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,81 @@
# PhenoPY
<h1 align="center">
<a href='https://github.com/JavierLopatin/PhenoPY'><img src='data/logo.svg' align="right" height="300" /></a>
<a href='https://github.com/JavierLopatin/PhenoSensing'><img src='phenosensing/data/logo.svg' align="right" height="300" /></a>
PhenoSensing
</h1>

<h4 align="center">Python bindings for Phenological analysis of Remote Sensing data </h4>
<h4 align="center">Land surface phenology from satellite image time series, built on xarray and Dask</h4>

<p align="center">
<a href="http://forthebadge.com">
<img src="http://forthebadge.com/images/badges/made-with-python.svg"
alt="Gitter">
</a>
<a href="http://forthebadge.com"><img src="http://forthebadge.com/images/badges/built-with-love.svg"></a>
<a href="http://forthebadge.com">
<img src="http://forthebadge.com/images/badges/built-with-science.svg">
</a>
<img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10+">
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
</p>

### Library dependencies:
- Python < 3.6
- rasterstats
- rasterio
- xarrar
- rioxarray
- shapely
- pandas
- numpy
- scipy
- matplotlib
- tqdm
PhenoSensing reconstructs a smoothed seasonal *phenological shape* for every pixel of
a vegetation-index (NDVI, EVI, kNDVI, SIF, …) time series and extracts **16
land-surface-phenology (LSP) metrics** from it, with first-class support for
**interannual** analysis and the **Southern Hemisphere**.

## Highlights

### Documentation comming soon...
- A clean `xarray` accessor: `da.pheno.PhenoShape(...).pheno.PhenoLSP()`.
- **16 LSP metrics**: SOS, POS, EOS, vSOS/vPOS/vEOS, LOS, MSP, MAU, vMSP/vMAU,
amplitude, integral, rates of greening/senescence, and skewness.
- **Interannual time series** via a moving multi-year window
(`get_timeseries_metrics`).
- **Curvature** and **(segmented) RMSE** goodness-of-fit.
- **Southern-Hemisphere** day-of-year reordering.
- **Dask-aware** for larger-than-memory rasters.

### Example:
> [!NOTE]
> PhenoSensing is the modernized successor to **PhenoPY**, renamed because
> `phenopy` is taken on PyPI by an unrelated clinical-phenotyping tool. It
> installs and imports as **`phenosensing`** (the xarray accessor stays
> `da.pheno`). A PyPI/conda release is in preparation.

Monthly time series data of SIF (satellite-retrieved solar-induced chlorophyll fluorescence) depicted from Chen et al. (2022; https://www.nature.com/articles/s41597-022-01520-1). The area here is a small sample for Chile.
## Installation

See the Notebook at: https://github.com/JavierLopatin/PhenoPY/blob/master/phenopy/ExampleData.ipynb
From source for now (a PyPI/conda release is in progress):

```bash
git clone https://github.com/JavierLopatin/PhenoSensing.git
cd PhenoSensing

# Recommended: conda dev environment (reliable geospatial stack)
conda env create -f environment-dev.yml
conda activate phenosensing
pip install -e ".[fit,plot,test]"
```

Optional extras: `plot` (matplotlib/folium/pyproj), `fit`
(whittaker-eilers/pymannkendall), `kde` (KDEpy), `fast` (numba),
`test`, `docs`.

## Quickstart

```python
import phenosensing
from phenosensing import load_sample

# Bundled example: SIF time series over Chile (2001-2020)
da = load_sample("SIF") # (time, y, x) with doy/year coords

# 1. Reconstruct a smoothed phenological shape per pixel
shape = da.pheno.PhenoShape(interpolType="linear", rollWindow=5, nGS=52)

# 2. Extract the 16 land-surface-phenology metrics
lsp = shape.pheno.PhenoLSP() # xarray.Dataset: sos, pos, eos, ...

# 3. Interannual time series via a moving multi-year window
ts = da.pheno.get_timeseries_metrics(window_length=3, metric=["sos", "pos", "eos"])
```

A full walk-through is in the example notebook:
[`phenosensing/ExampleData.ipynb`](https://github.com/JavierLopatin/PhenoSensing/blob/master/phenosensing/ExampleData.ipynb),
and an **executed tutorial exercising every function** is in
[`examples/tutorial.ipynb`](examples/tutorial.ipynb).
The example SIF data is a small sample over Chile, derived from
[Chen et al. (2022)](https://www.nature.com/articles/s41597-022-01520-1).

## License

MIT — see [LICENSE.txt](LICENSE.txt).
1 change: 0 additions & 1 deletion __init__.py

This file was deleted.

96 changes: 96 additions & 0 deletions benchmarks/bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Benchmarks for PhenoSensing's two scaling paths.

Run from the repo root in the dev env::

python benchmarks/bench.py

1. **Reconstruction** (``PhenoShape``, linear): the Numba ``[fast]`` kernel vs the
pure-Python path -- a single-core, in-memory acceleration of the hot loop.
2. **Extraction** (``PhenoLSP``): eager single-core vs chunked + the Dask
``processes`` scheduler -- i.e. how the *single* NumPy/scipy implementation
scales across cores without a second code path (no Numba kernel needed).

Both checks assert the parallel result is identical to the serial one.
"""

import time

import dask
import numpy as np
import xarray as xr

import phenosensing # noqa: F401 (registers the `pheno` accessor)
from phenosensing import _numba
from phenosensing.io import load_sample


def tile(da, reps):
"""Tile the sample spatially to build a larger synthetic raster."""
big = xr.concat([da] * reps, dim="y")
big = xr.concat([big] * reps, dim="x")
return big.assign_coords(y=np.arange(big.sizes["y"]), x=np.arange(big.sizes["x"]))


def timed(fn):
t0 = time.perf_counter()
out = fn()
np.asarray(out.values) # realise the computation
return time.perf_counter() - t0, out


def bench_reconstruction(da):
"""PhenoShape(linear): Numba fast path vs pure-Python (single core, in memory)."""
print("\n== Reconstruction: PhenoShape(linear) ==")
params = dict(interpolType="linear", rollWindow=5, nGS=52)
if _numba.NUMBA_AVAILABLE:
load_sample("SIF").pheno.PhenoShape(**params) # warm up the JIT (exclude compile time)
t_numba, out_numba = timed(lambda: da.pheno.PhenoShape(**params))
print(f" numba [fast] : {t_numba:6.2f} s")

orig = _numba.NUMBA_AVAILABLE
_numba.NUMBA_AVAILABLE = False
try:
t_py, out_py = timed(lambda: da.pheno.PhenoShape(**params))
finally:
_numba.NUMBA_AVAILABLE = orig
print(f" pure-Python : {t_py:6.2f} s")
if t_numba > 0:
print(f" speedup : x{t_py / t_numba:.1f}")
np.testing.assert_allclose(out_numba.values, out_py.values, rtol=1e-5, equal_nan=True)
print(" identical : OK")
return out_numba # reuse as the PhenoLSP input


def bench_extraction(shape, n_tiles=4):
"""PhenoLSP: eager (1 core) vs chunked + Dask 'processes' (multi-core)."""
print("\n== Extraction: PhenoLSP (eager vs Dask processes) ==")
t_eager, lsp_eager = timed(lambda: shape.pheno.PhenoLSP().compute())
print(f" eager (1 core) : {t_eager:6.2f} s")

cy = max(shape.sizes["y"] // n_tiles, 1)
cx = max(shape.sizes["x"] // n_tiles, 1)
shape_ch = shape.chunk({"y": cy, "x": cx})
with dask.config.set(scheduler="processes"):
t_par, lsp_par = timed(lambda: shape_ch.pheno.PhenoLSP().compute())
print(f" Dask processes : {t_par:6.2f} s ({n_tiles * n_tiles} tiles)")
if t_par > 0:
print(f" speedup : x{t_eager / t_par:.1f}")
for band in lsp_eager.data_vars:
np.testing.assert_allclose(
lsp_eager[band].values, lsp_par[band].values, rtol=1e-5, equal_nan=True
)
print(" identical : OK")


def main():
da = tile(load_sample("SIF"), reps=8)
npix = da.sizes["y"] * da.sizes["x"]
print(f"raster: {dict(da.sizes)} ({npix} pixels, {da.sizes['time']} time steps)")
print(f"numba available: {_numba.NUMBA_AVAILABLE}")

shape = bench_reconstruction(da)
bench_extraction(shape)


if __name__ == "__main__":
main()
41 changes: 41 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# API reference

## The `pheno` accessor

::: phenosensing.phenosensing.Pheno
options:
members:
- PhenoShape
- PhenoLSP
- RMSE
- get_timeseries_metrics

## Analysis layers

::: phenosensing.trends.trend

::: phenosensing.anomaly.anomaly

::: phenosensing.uncertainty.uncertainty

## Method registries

::: phenosensing.reconstruction.list_reconstructors

::: phenosensing.extraction.list_extractors

## Shape analysis

::: phenosensing.curvature.get_curvature

::: phenosensing.season.n_seasons

## I/O

::: phenosensing.io.load_sample

::: phenosensing.io.list_samples

## Utilities

::: phenosensing.utils.reorder_southern_hemisphere
29 changes: 29 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# PhenoSensing

Land surface phenology metrics from satellite image time series, built on
[xarray](https://docs.xarray.dev) and Dask.

PhenoSensing reconstructs a smoothed seasonal *phenological shape* for every pixel of
a vegetation-index time series and extracts 16 land-surface-phenology (LSP)
metrics from it — start/peak/end of season, rates, integrals, amplitude and
more — with first-class support for **interannual** analysis and the **Southern
Hemisphere**.

## Highlights

- A clean `xarray` accessor: `da.pheno.PhenoShape(...).pheno.PhenoLSP()`.
- 16 LSP metrics (SOS, POS, EOS, vSOS/vPOS/vEOS, LOS, MSP, MAU, amplitude,
integral, rates of greening/senescence, skewness).
- Interannual time series via a moving multi-year window
(`get_timeseries_metrics`).
- Curvature and (segmented) RMSE goodness-of-fit.
- Southern-Hemisphere day-of-year reordering.
- Dask-aware for larger-than-memory rasters.

See **[Quickstart](quickstart.md)** to get going in a few lines, or the
**[API reference](api.md)** for the full surface.

!!! note
PhenoSensing is being modernized toward a PyPI/conda release. The core accessor
API is stable, while new reconstruction and extraction methods are being
added.
33 changes: 33 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Installation

PhenoSensing is being prepared for a PyPI/conda release. For now, install from source.

## Conda development environment (recommended)

The geospatial stack (GDAL / rasterio / rioxarray) resolves most reliably on
conda-forge:

```bash
git clone https://github.com/JavierLopatin/PhenoSensing.git
cd PhenoSensing
conda env create -f environment-dev.yml
conda activate phenosensing
pip install -e ".[fit,plot,test]"
```

## Plain pip

```bash
pip install -e ".[fit,plot]"
```

## Optional extras

| Extra | Adds |
| ------ | --------------------------------------------------- |
| `plot` | matplotlib, folium, pyproj (plotting / maps) |
| `fit` | whittaker-eilers, pymannkendall (smoothing, trends) |
| `kde` | KDEpy (non-parametric reconstruction) |
| `fast` | numba (accelerated kernels) |
| `test` | pytest, pytest-cov, ruff |
| `docs` | mkdocs-material, mkdocstrings |
Loading
Loading