Skip to content

Commit 3726721

Browse files
committed
Add rolling tail dependence verb
1 parent da89119 commit 3726721

File tree

4 files changed

+106
-3
lines changed

4 files changed

+106
-3
lines changed

docs/reference/verbs_stats.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,26 @@ import xarray as xr
4949
corr = xr.corr(ndvi_z, climate_anom, dim="time")
5050
```
5151

52-
Rolling synchrony functions such as `cubedynamics.rolling_corr_vs_center` and `cubedynamics.rolling_tail_dep_vs_center` live outside the verbs namespace.
52+
Rolling synchrony functions such as `cubedynamics.rolling_corr_vs_center` remain outside the verbs namespace.
5353

5454
Use these stats alongside transform verbs to build climate–vegetation synchrony analyses.
55+
56+
### `v.rolling_tail_dep_vs_center(window, dim="time", min_periods=5, tail_quantile=0.8)`
57+
58+
Compute a rolling contrast between variability in the upper tail and variability across the full window. The result preserves the input cube shape (e.g., `(time, y, x)`).
59+
60+
```python
61+
from cubedynamics import pipe, verbs as v
62+
63+
td = (
64+
pipe(ndvi_z)
65+
| v.rolling_tail_dep_vs_center(
66+
window=90,
67+
dim="time",
68+
min_periods=5,
69+
tail_quantile=0.8,
70+
)
71+
)
72+
```
73+
74+
This verb rolls along ``dim`` and, for each window, compares the variance of values above ``tail_quantile`` to the variance of the entire window. Higher values indicate stronger variability in the tails relative to the center.

src/cubedynamics/verbs/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from .custom import apply
1515
from .flatten import flatten_cube, flatten_space
1616
from .models import fit_model
17-
from .stats import anomaly, mean, variance, zscore
17+
from .stats import anomaly, mean, rolling_tail_dep_vs_center, variance, zscore
1818

1919

2020
def show_cube_lexcube(**kwargs):
@@ -61,6 +61,7 @@ def _op(obj):
6161
"month_filter",
6262
"flatten_space",
6363
"flatten_cube",
64+
"rolling_tail_dep_vs_center",
6465
"variance",
6566
"correlation_cube",
6667
"to_netcdf",

src/cubedynamics/verbs/stats.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,56 @@ def _op(obj: xr.Dataset | xr.DataArray) -> xr.Dataset | xr.DataArray:
111111
return _op
112112

113113

114-
__all__ = ["anomaly", "mean", "variance", "zscore"]
114+
def rolling_tail_dep_vs_center(
115+
window: int,
116+
*,
117+
dim: str = "time",
118+
min_periods: int = 5,
119+
tail_quantile: float = 0.8,
120+
):
121+
"""Return a rolling "tail dependence vs center" contrast along ``dim``.
122+
123+
For each rolling window this computes the difference between variability in
124+
the upper tail (values above ``tail_quantile``) and variability across the
125+
full window. The verb preserves the original cube shape.
126+
127+
Parameters
128+
----------
129+
window : int
130+
Rolling window size in number of time steps.
131+
dim : str, optional
132+
Dimension to roll over (default: ``"time"``).
133+
min_periods : int, optional
134+
Minimum periods in window required to compute the statistic.
135+
tail_quantile : float, optional
136+
Quantile threshold defining the upper tail (default: ``0.8``).
137+
"""
138+
139+
def _op(obj: xr.Dataset | xr.DataArray) -> xr.Dataset | xr.DataArray:
140+
_ensure_dim(obj, dim)
141+
142+
window_dim = f"{dim}_window"
143+
rolled = obj.rolling({dim: window}, min_periods=min_periods)
144+
constructed = rolled.construct(window_dim)
145+
146+
counts = constructed.count(dim=window_dim)
147+
center_var = constructed.var(dim=window_dim, skipna=True, keep_attrs=True)
148+
149+
q = constructed.quantile(tail_quantile, dim=window_dim)
150+
tail_vals = constructed.where(constructed >= q)
151+
tail_counts = tail_vals.count(dim=window_dim)
152+
tail_var = tail_vals.var(dim=window_dim, skipna=True, keep_attrs=True)
153+
154+
result = tail_var - center_var
155+
valid = (counts >= min_periods) & (tail_counts > 0)
156+
result = result.where(valid)
157+
158+
if isinstance(result, xr.DataArray) and obj.name:
159+
result = result.rename(f"{obj.name}_tail_dep_vs_center")
160+
161+
return result
162+
163+
return _op
164+
165+
166+
__all__ = ["anomaly", "mean", "rolling_tail_dep_vs_center", "variance", "zscore"]

tests/test_rolling_tail_dep.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import numpy as np
2+
import xarray as xr
3+
4+
from cubedynamics import pipe, verbs as v
5+
6+
7+
def test_rolling_tail_dep_vs_center_shape_and_nans():
8+
np.random.seed(0)
9+
time = np.arange(20)
10+
y = np.arange(3)
11+
x = np.arange(4)
12+
data = xr.DataArray(
13+
np.random.randn(len(time), len(y), len(x)),
14+
dims=("time", "y", "x"),
15+
coords={"time": time, "y": y, "x": x},
16+
name="ndvi",
17+
)
18+
19+
out = (pipe(data) | v.rolling_tail_dep_vs_center(window=5, dim="time", min_periods=5)).unwrap()
20+
21+
assert out.dims == data.dims
22+
assert out.shape == data.shape
23+
24+
# First few windows should be missing because of min_periods
25+
early = out.isel(time=slice(0, 4))
26+
assert np.isnan(early).all()
27+
28+
# Later windows should contain finite values for most pixels
29+
later = out.isel(time=slice(6, None))
30+
assert np.isfinite(later.values).any()

0 commit comments

Comments
 (0)