Change Detection¶
LandTrendr and CCDC, one function each for every kind of input, and the BFAST family at every level: one pixel, an in-memory array, a GeoTIFF on disk, or a Dask array. The xarray accessor forms are listed in Xarray Accessor.
LandTrendr¶
Tutorial: LandTrendr.
landtrendr¶
zeit.landtrendr(
data, years=None, direction="loss", band=None, max_segments=6,
pval_threshold=0.05, recovery_threshold=0.25,
prevent_fast_recovery=True, spike_threshold=0.9,
best_model_proportion=0.75, vertex_count_overshoot=3,
min_observations_needed=6, nodata="auto", fitted=False, ftv=None,
chunks=None, n_jobs=-1,
)
Segments annual time series with LandTrendr (Kennedy et al. 2010), every pixel in parallel in C++ / OpenMP. One function for every input: it reads what data is and returns the same xarray.Dataset of vertices, georeferenced when the input is. All parameters but data are keyword-only. It replaces run_landtrendr, run_landtrendr_array, run_landtrendr_image and DataArray.zeit.run_landtrendr; the accessor form is now DataArray.zeit.landtrendr.
data can be:
| Input | Example | Where the years come from |
|---|---|---|
A raster file, folder, glob or Zarr/NetCDF store: anything load_raster reads |
"LT_Stack_NDVI_Rondonia.tif" |
The dates load_raster finds (band descriptions such as yr1985, file names, a time coordinate) |
A (time, y, x) cube, numpy- or dask-backed |
the output of load_raster |
Its time coordinate. A dask cube stays lazy. |
A (time, band, y, x) cube or a Dataset |
cube with band="NBR" |
Its time coordinate; band names the index |
A numpy array (time, y, x) |
stack |
years= |
One pixel's series: a list, 1-D array or pandas.Series |
[5200, 5100, 2100, ...] |
years=, or the Series' index of dates or years |
LandTrendr needs one value per year: a series whose years repeat raises an error asking for annual composites first (build_annual_composites, or regularize_time_series(cube, freq="1YS")).
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
path, DataArray, Dataset, ndarray, list or pd.Series |
required | What to segment (see the table above). |
years |
1-D array | None |
Year of each time step. Only for numpy input and cubes without dates; otherwise taken from the time coordinate. |
direction |
str |
"loss" |
The change to look for. "loss": drops of the index, such as vegetation loss on NDVI, NBR, EVI or wetness. "gain": rises, for indices that rise with disturbance (SWIR, brightness) or to map regrowth. Output values are always in the original scale. |
band |
str or int |
None |
The index to segment in a (time, band, y, x) cube or a Dataset (band or variable name). |
max_segments |
int |
6 |
Maximum segments per pixel. |
pval_threshold |
float |
0.05 |
Maximum p-value of an accepted model. |
recovery_threshold |
float |
0.25 |
Rejects recoveries faster than 1 / recovery_threshold years. |
prevent_fast_recovery |
bool |
True |
Kept for compatibility; has no effect (the recovery check always applies, as in the original). |
spike_threshold |
float |
0.9 |
Spike dampening (desawtooth). 1.0 disables it. |
best_model_proportion |
float |
0.75 |
Prefer the model with more vertices whose p-value is at most (2 - best_model_proportion) times the best, as in the original. 0.75 accepts models within 1.25× of the best p-value. Values above 1 make the rule stricter than the best model itself, so most pixels end up as a flat line. |
vertex_count_overshoot |
int |
3 |
Extra candidate vertices before pruning. |
min_observations_needed |
int |
6 |
Pixels with fewer valid years are not segmented. |
nodata |
"auto", float or None |
"auto" |
Value marking a missing year, which is left out of the fit (NaN always is). "auto": the raster's NoData value; for integer data without one, 0 (how Earth Engine exports masked pixels). A number: that value. None: only NaN. |
fitted |
bool |
False |
Also return the fitted trajectory, fitted (time, y, x): the series rebuilt from the vertices (LT-GEE's fitted values). |
ftv |
list of str |
None |
Other bands to fit to the vertices of band (LT-GEE's fitted-to-vertex bands), by name: bands of the (time, band, y, x) cube or variables of the Dataset. Each is desawtoothed and fitted segment by segment through the vertex years found, in its own scale (not flipped by direction), as the original's ftv_v1. |
chunks |
"auto", dict |
None |
Inputs read from disk: None reads the raster into memory; "auto" or a dict of chunk sizes keeps it lazy, so the result is computed block by block, for rasters larger than memory. |
n_jobs |
int |
-1 |
Threads. -1 uses all cores but one. |
Returns an xarray.Dataset:
| Variable | Dims | Type | Meaning |
|---|---|---|---|
vertex_year |
(vertex, y, x) |
int16 | Year of each vertex; 0 past the last one. |
vertex_value |
(vertex, y, x) |
float32 | Fitted value at each vertex; NaN past the last one. |
n_vertices |
(y, x) |
uint8 | Number of vertices (0: not segmented). |
rmse |
(y, x) |
float32 | RMSE of the fit: the noise estimate behind dsnr in extract_events. |
fitted |
(time, y, x) |
float32 | Only with fitted=True: the fitted trajectory. |
ftv_<band> |
(time, y, x) |
float32 | For each band in ftv: its series fitted to the vertices. |
vertex_value_<band> |
(vertex, y, x) |
float32 | For each band in ftv: its fitted value at each vertex year; NaN past the last vertex. |
The x/y coordinates and CRS of the input are kept, so the result goes straight to save_raster. attrs records the parameters, direction among them (the default event_type of extract_events). A single pixel's result has no y/x dims. With a dask cube or chunks=, the result is lazy.
ndvi = zeit.load_raster("LT_Stack_NDVI_Rondonia.tif") # (time, y, x), bands yr1985 ... yr2024
lt = zeit.landtrendr(ndvi) # looks for NDVI drops (direction="loss")
loss = zeit.extract_events(lt, min_magnitude=1500) # greatest loss per pixel
zeit.save_raster(loss, "lt_rondonia") # one GeoTIFF per metric
# A raster larger than memory: lazy, computed block by block while it is written
lt = zeit.landtrendr("nbr_1985_2024.tif", chunks="auto")
zeit.save_raster(zeit.extract_events(lt, min_magnitude=2000), "lt_nbr")
# One pixel's series
px = zeit.landtrendr([8100, 8000, 8200, 8050, 3100, 4200, 5300, 6100, 6800, 7300, 7500, 7600],
years=range(2010, 2022))
px.vertex_year.values # array([2010, 2013, 2014, 2018, 2021, 0, 0], dtype=int16)
Fitted to vertices (FTV)¶
ftv= describes the periods found on one index with other bands, as LT-GEE's ftv bands: segment on NBR, then fit NDVI, TCW or SWIR1 through the same vertex years.
stack = zeit.load_raster("landsat_indices.tif") # (time, band, y, x): nbr, ndvi, tcw, ...
lt = zeit.landtrendr(stack, band="nbr", ftv=["ndvi", "tcw"])
lt.ftv_ndvi # (time, y, x): NDVI fitted to the NBR vertices
lt.vertex_value_tcw # (vertex, y, x): TCW at each vertex year
It is a port of the original's ftv_v1.pro (with apply_fitted_trajectory_v1.pro), checked against it run under GDL. Each band is desawtoothed with spike_threshold and fitted segment by segment, early to late, by the better of a straight line between the vertices and a regression anchored at the segment's start (find_best_trace). A vertex year the band has no observation for (a cloud in that band only) moves to the band's nearest observation before it, or after it when that one is a vertex already. Missing first and last years get flat vertices, as in the segmentation. The bands must be on the same grid and years as the segmented one (see load_raster(..., like=)).
extract_events¶
zeit.metrics.extract_events(
result, event_type=None, sort_by="greatest", min_magnitude=0.0,
min_duration=1, pre_val_threshold=0.0, rmse_map=None, band=None,
)
Turns the result of any change detection algorithm into maps of one event per pixel, such as the greatest loss: the same variables whichever algorithm found the change, so maps of LandTrendr, CCDC and BFAST can be compared (agreement), combined, or validated the same way. Also exported as zeit.extract_events.
| Result of | Candidate events of a pixel |
|---|---|
landtrendr |
Each segment that goes in the direction of event_type. |
ccdc |
Each break followed by another segment; the change is the next segment's model minus the previous one's, both on the date of the break. A break at the end of the series, with no model after it yet, has no magnitude and is left out. |
bfast_monitor |
The break, with its magnitude (the median residual of the monitoring period). |
bfast_lite |
Each break, with magnitude_k. |
bfast |
Each trend break, with trend_magnitude_k. |
embedding_change |
Each year: the distance between its embedding and the year before's, with no direction (event_type="any"); dsnr against the pixel's median distance. |
| Parameter | Type | Default | Description |
|---|---|---|---|
result |
xr.Dataset |
required | The result of one of the algorithms above, in memory or dask. A numpy LandTrendr vertex stack (2 × vertices, rows, cols), years in the first half and values in the second, is also accepted. |
event_type |
str |
None |
"loss" (the value fell), "gain" (it rose) or "any" (either; the magnitude is then the size of the change). Default: the direction LandTrendr ran with ("loss" for a numpy stack), "any" for the other algorithms. LandTrendr takes "loss" or "gain". |
sort_by |
str |
"greatest" |
Which event to keep when a pixel has several: "greatest" (magnitude), "newest", "fastest", "longest" or "dsnr" (not for bfast, whose result keeps no noise estimate). |
min_magnitude |
float |
0.0 |
Ignore events smaller than this, in data units. A flat segment (magnitude 0) is never an event. |
min_duration |
int |
1 |
Ignore events shorter than this many years. |
pre_val_threshold |
float |
0.0 |
For losses, ignore events starting below this value (for gains, above). 0 disables. Needs the value before the change: LandTrendr, or CCDC with band. |
rmse_map |
np.ndarray |
None |
Numpy stacks only: (rows, cols) RMSE of each pixel's fit, which adds the dsnr output. A LandTrendr Dataset brings its own (rmse). |
band |
str |
None |
CCDC only: the band whose change is measured, e.g. "nir". Without it (and with several bands), the magnitude is the length of the change vector over the run's detection bands, with no direction (event_type="any"). |
Returns an xarray.Dataset on the same grid and CRS as result (lazy if result is), ready for save_raster:
| Variable | Meaning |
|---|---|
yod |
Year of the last observation before the change (0 = no event). For LandTrendr, the year of the vertex where the event starts; the first year in which the change is visible is yod + 1. For BFAST, the year of the time step before date. |
date |
Date of the first observation that shows the change (NaT = no event). For annual LandTrendr, January 1 of yod + 1; for CCDC, the break (t_break). |
magnitude |
Size of the change, in data units, positive in the direction of event_type. |
duration |
Years the change took. 1 is abrupt, and every break of CCDC and BFAST is. |
pre_val, post_val |
Fitted value before and after (NaN for BFAST, whose results keep no model, and for CCDC without band). |
rate |
magnitude / duration. |
dsnr |
Magnitude divided by the noise of the fit (LT-GEE's disturbance signal-to-noise ratio): LandTrendr's RMSE, the CCDC segment's RMSE (over several bands, the change vector standardized band by band), BFAST Monitor's sigma, BFAST Lite's residual standard deviation, the median yearly distance of an embedding. Values above 2–3 are rarely noise. |
For a numpy vertex stack, a dict of (rows, cols) arrays with the LandTrendr variables except date (dsnr only with rmse_map).
lt = zeit.landtrendr(ndvi)
loss = zeit.extract_events(lt, min_magnitude=2000)
first_year_of_loss = (loss.yod + 1).where(loss.yod > 0)
regrowth = zeit.extract_events(lt, event_type="gain", sort_by="newest") # same fit, rises
segments = zeit.ccdc(cube)
nir_loss = zeit.extract_events(segments, band="nir", event_type="loss") # the greatest NIR drop
any_change = zeit.extract_events(segments, sort_by="newest") # the latest break, any direction
bfm = zeit.extract_events(zeit.bfast_monitor(ndvi_16d, "2022-01-01"), event_type="loss")
agreement¶
Where and when several change maps agree, e.g. LandTrendr, CCDC and BFAST on the same area. Each map is the result of extract_events (or any yod map), so every algorithm is compared by the year of the last observation before the change.
| Parameter | Type | Default | Description |
|---|---|---|---|
events |
xr.Dataset, DataArray or dict |
required | Two or more maps of events on the same grid, or one dict of name → map. Without a dict, maps are named after their algorithm. Maps on different grids go through load_raster(m, like=...) first. |
tolerance |
int |
1 |
Years two events may be apart and still agree (0: the same year). |
Returns an xarray.Dataset on the grid of the maps:
| Variable | Meaning |
|---|---|
yod |
The consensus year: the one the most maps agree with within tolerance (ties: the year more maps give exactly, then the earliest); 0 where no map has an event. |
n_detected |
How many maps have an event at the pixel. |
n_agree |
How many of them agree with the consensus year. |
spread |
Latest minus earliest year among the maps with an event; NaN without one. |
agrees |
(map, y, x): 1 for the maps that agree with the consensus year. |
lt = zeit.extract_events(zeit.landtrendr(nbr_annual))
cc = zeit.extract_events(zeit.ccdc(cube), band="nbr", event_type="loss")
bf = zeit.extract_events(zeit.bfast_lite(nbr_16d), event_type="loss")
agree = zeit.agreement({"landtrendr": lt, "ccdc": cc, "bfast": bf}, tolerance=1)
confident = agree.yod.where(agree.n_agree >= 2, 0) # change two of the three algorithms see
# An ensemble in the LCMS way: the events of each algorithm as the features of a classifier
features = xr.merge([ev[["magnitude", "dsnr"]].fillna(0).rename({v: f"{name}_{v}" for v in ("magnitude", "dsnr")})
for name, ev in {"lt": lt, "ccdc": cc, "bfast": bf}.items()], compat="override")
model = zeit.train_classifier(features, "reference.gpkg", label="change")
change = zeit.classify(features, model)
apply_vertices¶
Describes another band with the vertex years found on the primary index, by interpolating that band's raw values at those years. For LT-GEE's fitted-to-vertex bands (the band fitted through the vertices, as the original does), use landtrendr(..., ftv=[...]).
| Parameter | Type | Default | Description |
|---|---|---|---|
vertex_years |
1-D array | required | Vertex years from the primary index. |
other_band_years |
1-D array | required | Years of the other band's series. |
other_band_values |
1-D array | required | The other band's values. |
Returns a list of vertices, [{"year": 1985, "value": ...}, ...].
px = zeit.landtrendr(nbr, years=years) # one pixel's NBR series
vertex_years = px.vertex_year.values[: int(px.n_vertices)]
ftv = zeit.apply_vertices(vertex_years, years, swir1) # the same pixel's SWIR1
CCDC¶
Tutorial: CCDC.
ccdc¶
zeit.ccdc(
data, qa=None, dates=None, bands=None, max_segments=6,
conseq_anom=6, chi2_prob_threshold=0.99,
tmax_cg_prob_threshold=0.999999, detection_bands=None, num_c=8,
tmask_bands=None, thermal_band=None, valid_range=(0.0, 10000.0),
thermal_range=(-9320.0, 7070.0), nodata="auto", chunks=None,
n_jobs=-1,
)
Continuous Change Detection and Classification (Zhu & Woodcock 2014): fits a harmonic model to every stable period of each pixel's multi-band series and dates the breaks between them, every pixel in parallel in C++ / OpenMP. The engine is a port of the original MATLAB code, validated segment for segment against it. One function for every input: it reads what data is and returns the same xarray.Dataset of segments, georeferenced when the input is. All parameters but data are keyword-only. It replaces zeit.ccdc.run_ccdc, run_ccdc_array, run_ccdc_image and DataArray.zeit.run_ccdc; the accessor form is now DataArray.zeit.ccdc.
data can be:
| Input | Example | Where the dates and bands come from |
|---|---|---|
A (time, band, y, x) cube, numpy- or dask-backed |
the output of load_raster or build_time_series |
Its time and band coordinates. A dask cube stays lazy. |
A raster file whose bands are named date_band, or anything load_raster reads |
"landsat_sr.tif" (bands 2008-01-05_blue, …, as save_raster writes a cube), a folder read with pattern= |
The dates and band names load_raster finds |
| A raster interleaved by date without band names (all bands of date 1, then of date 2, …) | "stack.tif" |
dates=, and bands=: the names of the bands of each date |
A numpy array (time, band, y, x) |
stack |
dates= |
One pixel: a (time, band) DataArray, or a pandas.DataFrame indexed by date with one column per band |
cube.isel(y=0, x=0) |
Its time coordinate or index |
Values are surface reflectance × 10,000 (the original's thresholds, range test and cloud screen are defined on that scale), bands ordered Blue, Green, Red, NIR, SWIR1, SWIR2 [, brightness temperature], unless bands= orders them. Dates can be any dates; they are converted to ordinal days internally.
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
path, DataArray, ndarray or pd.DataFrame |
required | What to run on (see the table above). |
qa |
str, DataArray or ndarray |
None |
Fmask codes per date: 0 clear land, 1 water, 2 cloud shadow, 3 snow, 4 cloud, 255 no observation. The name of a band of the cube (it is then left out of the spectral bands), a (time, y, x) cube or array ((time,) for one pixel), or None: every date is clear, and dates with NaN are no observation. |
dates |
list | None |
Date of each time step (strings, datetimes, datetime64). Only for numpy input and cubes or rasters without dates; otherwise taken from the time coordinate. |
bands |
list of str or int |
None |
Spectral bands to use, by name or 0-based position, in the order above. For a raster interleaved by date without band names: the names of the bands of each date (including the QA band, if any). |
max_segments |
int |
6 |
Maximum segments returned per pixel. |
conseq_anom |
int |
6 |
Consecutive anomalous observations that confirm a break (the original's conse). |
chi2_prob_threshold |
float |
0.99 |
Change probability for the chi-squared threshold. Lower is more sensitive. |
tmax_cg_prob_threshold |
float |
0.999999 |
Outlier probability: observations beyond it are dropped as noise (for example missed clouds). |
detection_bands |
list of str or int |
None |
Bands used for change detection, by name or 0-based position among the spectral bands. Default: Green to SWIR2. |
num_c |
int |
8 |
Maximum number of harmonic coefficients (4, 6 or 8). |
tmask_bands |
list of str or int |
None |
Bands of the internal Tmask cloud screen. Default: Green and SWIR1. |
thermal_band |
str or int |
None |
The brightness-temperature band (°C × 100), if any. |
valid_range |
tuple |
(0.0, 10000.0) |
Valid range of the optical bands. |
thermal_range |
tuple |
(-9320.0, 7070.0) |
Valid range of the thermal band. |
nodata |
"auto", float or None |
"auto" |
Value marking a missing observation besides NaN. "auto": the raster's NoData value; for integer data without one, 0. A number: that value. None: only NaN. |
chunks |
"auto", dict |
None |
Inputs read from disk: None reads the raster into memory; "auto" or a dict of chunk sizes keeps it lazy, so the result is computed block by block, for rasters larger than memory. |
n_jobs |
int |
-1 |
Threads. -1 uses all cores but one. |
Returns an xarray.Dataset:
| Variable | Dims | Type | Meaning |
|---|---|---|---|
t_start, t_end |
(segment, y, x) |
datetime64 | First and last date of each segment; NaT past the last one. |
t_break |
(segment, y, x) |
datetime64 | Date of the break that ended the segment; NaT if it ended without one, and past the last segment. |
n_segments |
(y, x) |
uint8 | Number of segments (0: no model). |
rmse |
(segment, band, y, x) |
float32 | RMSE of each band's fit; NaN past the last segment. |
coefs |
(segment, band, coef, y, x) |
float32 | The harmonic model of each band, coef = a0, c1, a1, b1, a2, b2, a3, b3; NaN past the last segment. |
The model is a0 + c1·t + a1 cos(wt) + b1 sin(wt) + a2 cos(2wt) + b2 sin(2wt) + a3 cos(3wt) + b3 sin(3wt) on the original's time axis: t = ordinal day + 366 (MATLAB datenum), w = 2π / 365.25. predict_synthetic_image evaluates it on any date. The x/y coordinates and CRS of the input are kept, so the result goes straight to save_raster: one GeoTIFF per variable, dates as decimal years (NaN: none), and coefs.tif with one band per segment, band and coefficient (1_blue_a0, 1_blue_c1, …). attrs records the parameters. A single pixel's result has no y/x dims. With a dask cube or chunks=, the result is lazy.
cube = zeit.load_raster("landsat_sr.tif") # (time, band, y, x): blue ... swir2 and fmask
segments = zeit.ccdc(cube, qa="fmask") # QA band by name, left out of the spectral bands
first_break = segments.t_break.isel(segment=0) # NaT where nothing changed
july = zeit.predict_synthetic_image(segments, "2020-07-01") # (band, y, x)
zeit.save_raster(segments, "ccdc_out") # t_start.tif, ..., coefs.tif
# A raster larger than memory: lazy, computed block by block while it is written
segments = zeit.ccdc("landsat_sr.tif", qa="fmask", chunks="auto")
zeit.save_raster(segments, "ccdc_out")
# A stack interleaved by date without band names
segments = zeit.ccdc("stack.tif", dates=dates, qa="fmask",
bands=["blue", "green", "red", "nir", "swir1", "swir2", "fmask"])
# One pixel: a DataFrame indexed by date, one column per band
px = zeit.ccdc(df, qa="fmask")
px[["t_start", "t_end", "t_break"]].to_dataframe().dropna(subset=["t_start"])
predict_synthetic_image¶
Evaluates each pixel's CCDC model on a date, giving a cloud-free image for any day, including days without an acquisition. Each pixel uses the segment that covers the date; before the first segment, the first one; after the last, the last one.
| Parameter | Type | Default | Description |
|---|---|---|---|
segments |
xr.Dataset |
required | The result of ccdc, in memory or dask (a lazy result gives a lazy image). |
date |
str, datetime, datetime64 or int |
required | The date to predict; an int is a Python ordinal day. |
num_bands |
int |
6 |
Legacy numpy stacks only (below): number of bands. |
target_julian_day |
int |
None |
Former name of date. |
Returns a (band, y, x) DataArray on the grid and CRS of segments ((band,) for one pixel), NaN where a pixel has no model.
july = zeit.predict_synthetic_image(segments, "2019-07-15")
zeit.save_raster(july, "synthetic_2019-07-15.tif")
# One pixel's curve, on the first day of every month
px = zeit.ccdc(df, qa="fmask")
days = pd.date_range("2010-01-01", "2020-12-01", freq="MS")
nir = [float(zeit.predict_synthetic_image(px, d).sel(band="nir")) for d in days]
The numpy layout of older versions, (max_segments, 3 + 9 × bands, rows, cols) (per segment t_start, t_end and t_break in ordinal days, then each band's RMSE and 8 coefficients), is still accepted: predict_synthetic_image(stack, target_julian_day=day, num_bands=6) returns a (num_bands, rows, cols) float32 array.
CODED (forest degradation)¶
coded¶
zeit.coded(
data, start=None, train_years=3.0, consec=3, thresh=3.0,
min_years=3.0, min_obs=6, direction="loss", max_events=3,
training=None, label="label", forest_label=None, forest_ndfi=0.5,
endmembers="souza2005", bands=None, scale="auto",
cloud_threshold=0.05, seed=42, nodata="auto", chunks=None,
n_jobs=-1,
)
Continuous Degradation Detection (CODED; Bullock, Woodcock & Olofsson 2020): forest degradation (a disturbance after which the land is still forest: selective logging, understory fire) and deforestation (after which it is not) from the NDFI of every Landsat observation. Per pixel, in C++: the fractions and NDFI of every observation (unmix, clouds masked by their fraction); a model of the NDFI (a constant and an annual harmonic) over a training period, with its RMSE; monitoring after it, where consec observations in a row more than thresh RMSEs below the model are a change; after each change, a new model of the next min_years, which is the land cover after it. Forest or not comes from the models: a random forest on their coefficients trained on land cover points, as in CODED, or else the model's mean NDFI against forest_ndfi. Also exported as zeit.coded; CLI: zeit coded. Tutorial: Forest Degradation (CODED).
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
DataArray, Dataset or path |
required | Landsat-like reflectance (time, band, y, x) with blue, green, red, NIR, SWIR1 and SWIR2 (every observation, not composites), or the result of unmix. |
start |
year or date | None |
Start of the monitoring; the training period is the train_years before it. Default: the training period starts with the series. |
train_years |
float |
3.0 |
Years of the training period. |
consec |
int |
3 |
Consecutive observations beyond the threshold that make a change. |
thresh |
float |
3.0 |
The threshold, in RMSEs of the model. |
min_years |
float |
3.0 |
Years after a change modelled as its new land cover; the least time between changes. |
min_obs |
int |
6 |
Least observations to fit a model. |
direction |
str |
"loss" |
"loss" (drops of the NDFI, CODED's disturbances) or "both". |
max_events |
int |
3 |
Changes kept per pixel. |
training |
GeoDataFrame or path |
None |
Land cover points of the training period, for the random forest. |
label, forest_label |
"label", None |
Column with the class, and the class that is forest (default: "forest" or 1). |
|
forest_ndfi |
float |
0.5 |
Without training: forest where the model's mean NDFI is at least this. |
endmembers, bands, scale, cloud_threshold |
The unmixing, as in unmix. |
||
seed, nodata, chunks, n_jobs |
42, "auto", None, -1 |
Random forest seed; as elsewhere. |
Returns an xarray.Dataset:
| Variable | Meaning |
|---|---|
t_change |
(event, y, x): date of each change, its first observation beyond the threshold. |
t_before |
The observation before it. |
ndfi_change |
Mean NDFI residual of the change's observations (negative: the NDFI fell). |
type |
1 degradation (forest after the change), 2 deforestation, 3 disturbance (too few observations after it to tell), 0 none. |
post_ndfi |
Mean NDFI of the model after each change. |
n_events, forest |
Changes per pixel; forest in the training period. |
ndfi_mean, rmse, n_train |
The training model: mean NDFI, RMSE, observations. |
strata |
1 forest, 2 non-forest (no change), 3 degradation, 4 deforestation, 5 disturbance, by the first change, with flag_meanings. |
The strata map is what CODED's authors recommend using the result for: the strata of a sample from which the area of degradation is estimated (Validation). extract_events turns the changes into the same event maps as the other algorithms.
result = zeit.coded(landsat, start=2000, training="land_cover_1997_1999.gpkg")
design = zeit.sampling_design(result.strata, expected_ua={"degradation": 0.6, "deforestation": 0.8})
points = zeit.stratified_sample(result.strata, design=design)
BFAST family¶
Ports of R's bfast package: bfast_monitor (near-real-time monitoring), bfast_lite (multiple breakpoints in one pass) and bfast (classic iterative trend and season breaks). Tutorials: BFAST Monitor, BFAST Lite, BFAST.
Like landtrendr and ccdc, each is one function for every input: it reads what data is and returns an xarray.Dataset with one variable per metric, georeferenced when the input is. All parameters but data (and monitor_start) are keyword-only. They replace run_bfast_monitor_image, run_bfast_lite_image, run_bfast_image, zeit.bfast.run_bfast_monitor_dask, run_bfast_lite_dask, run_bfast_dask and the accessor methods DataArray.zeit.run_bfast_monitor, run_bfast_lite and run_bfast; the accessor forms are now DataArray.zeit.bfast_monitor, bfast_lite and bfast. The engine module is internal (zeit._bfast).
bfast_monitor¶
zeit.bfast_monitor(
data, monitor_start, dates=None, start_time=None, frequency=None,
order=3, h=0.25, period=10, alpha=0.05, min_valid=10, band=None,
nodata="auto", chunks=None, n_jobs=-1,
)
Near-real-time disturbance monitoring (bfastmonitor, Verbesselt et al. 2012): fits a linear trend plus order harmonics on the history before monitor_start, then flags the first observation after it where the OLS-MOSUM process crosses its boundary (type="OLS-MOSUM", history="all"). Every pixel runs in parallel in C++ / OpenMP.
data can be (the same for bfast_lite, bfast, mann_kendall and phenology):
| Input | Example | Where the dates come from |
|---|---|---|
A raster file, folder, glob or Zarr/NetCDF store: anything load_raster reads |
"ndvi_16d.tif" |
The dates load_raster finds (band descriptions such as 2020-01-15, file names, a time coordinate) |
A (time, y, x) cube, numpy- or dask-backed |
the output of load_raster or regularize_time_series |
Its time coordinate. A dask cube stays lazy. |
A (time, band, y, x) cube or a Dataset |
cube with band="ndvi" |
Its time coordinate; band names the index |
A numpy array (time, y, x) |
stack |
dates= |
One pixel's series: a list, 1-D array or pandas.Series |
[0.71, 0.74, 0.69, ...] |
dates=, or the Series' index of dates |
The time axis. The BFAST family works on a regular series, like R's ts: observation i is at start_time + i / frequency, in decimal years. Both are read from the dates: frequency from their median spacing (23 for 16-day composites, 12 for monthly, 1 for annual data) and start_time from the first date. Composite irregular data to a fixed step first (regularize_time_series), with NaN for the gaps. A series without dates (numpy without dates=, a plain list) needs both start_time and frequency.
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
path, DataArray, Dataset, ndarray, list or pd.Series |
required | What to monitor (see the table above). |
monitor_start |
str, datetime or float |
required | Start of the monitoring period: a date ("2022-01-01") or a decimal year (2022.0). Observations before it are the history. |
dates |
list | None |
Date of each time step. Only for numpy input, single series and cubes without dates; otherwise taken from the time coordinate. |
start_time |
float |
None |
Decimal year of the first observation. Default: from the first date. |
frequency |
int |
None |
Observations per year. Default: from the median spacing of the dates. |
order |
int |
3 |
Seasonal harmonics. |
h |
float |
0.25 |
MOSUM window, as a fraction of the history: 0.25, 0.5 or 1.0. |
period |
int |
10 |
Boundary horizon, in history lengths: 2, 4, 6, 8 or 10. |
alpha |
float |
0.05 |
Significance level. |
min_valid |
int |
10 |
Minimum valid history observations. |
band |
str or int |
None |
The index to use in a (time, band, y, x) cube or a Dataset (band or variable name). |
nodata |
"auto", float or None |
"auto" |
Value marking a missing observation besides NaN. "auto": the raster's NoData value; for integer data without one, 0 (how Earth Engine exports masked pixels). A number: that value. None: only NaN. |
chunks |
"auto", dict |
None |
Inputs read from disk: None reads the raster into memory; "auto" or a dict of chunk sizes keeps it lazy, so the result is computed block by block, for rasters larger than memory. |
n_jobs |
int |
-1 |
Threads. -1 uses all cores but one. |
h and period index a table of simulated critical values (from strucchangeRcpp), so other values raise an error.
Returns an xarray.Dataset of (y, x) float32 maps:
| Variable | Meaning |
|---|---|
breakpoint |
Decimal year of the first detected break on R's regular ts axis (start_time + breakpoint_idx / frequency); NaN if none. |
breakpoint_idx |
0-based index of that observation in the series. |
magnitude |
Median residual over the monitoring period: size and direction of the shift (negative: lower than expected). |
sigma |
Residual standard error of the history fit. |
n_history |
Valid observations in the history. |
has_break |
1 if a break was detected, else 0. |
valid |
0 if the history was too short to fit; the other variables are then NaN. |
break_date |
Date of the break's observation, from the data's dates (NaT if none). |
The x/y coordinates and CRS of the input are kept, so the result goes straight to save_raster: one GeoTIFF per variable for a folder, or one band per variable for a .tif path. attrs records the parameters, including the start_time and frequency used. A single pixel's result has no y/x dims. With a dask cube or chunks=, the result is lazy.
ndvi = zeit.load_raster("ndvi_16d.tif") # (time, y, x): 16-day composites from January 2010
bfm = zeit.bfast_monitor(ndvi, "2022-01-01") # start_time 2010.0 and frequency 23, from the dates
alerts = (bfm.has_break == 1) & (bfm.magnitude < -0.1)
zeit.save_raster(bfm, "bfm_out") # breakpoint.tif, ..., valid.tif
# A raster larger than memory: lazy, computed block by block while it is written
bfm = zeit.bfast_monitor("ndvi_16d.tif", 2022.0, chunks="auto")
zeit.save_raster(bfm, "bfm_out")
# One pixel's series without dates: give the time axis
px = zeit.bfast_monitor(values, 2022.0, start_time=2010.0, frequency=23)
float(px.breakpoint) # decimal year, NaN if no break
bfast_lite¶
zeit.bfast_lite(
data, dates=None, start_time=None, frequency=None, order=3,
h=0.15, max_breaks=5, min_valid=20, band=None, nodata="auto",
chunks=None, n_jobs=-1,
)
Multiple breakpoints in one pass (bfastlite, Masiliūnas et al. 2021): fits response ~ trend + harmon and chooses the number and position of breaks with the Bai & Perron dynamic program and the LWZ criterion. Input, time axis and output are as for bfast_monitor.
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
path, DataArray, Dataset, ndarray, list or pd.Series |
required | What to segment (see bfast_monitor). |
dates, start_time, frequency |
None, None, None |
The time axis, as in bfast_monitor. |
|
order |
int |
3 |
Seasonal harmonics. |
h |
float |
0.15 |
Minimum segment size, as a fraction of the observations. |
max_breaks |
int |
5 |
Break slots in the output (formerly max_breaks_output). |
min_valid |
int |
20 |
Minimum valid observations. |
band, nodata, chunks, n_jobs |
None, "auto", None, -1 |
As in bfast_monitor. |
Returns an xarray.Dataset of (y, x) float32 maps:
| Variable | Meaning |
|---|---|
n_breaks |
Number of breaks chosen by the criterion (0 if none). |
rss |
Residual sum of squares of the selected model. |
lwz |
Value of the LWZ criterion. |
n_valid |
Valid observations used. |
valid |
1 if the series had enough observations to fit. |
breakpoint_idx_1 … breakpoint_idx_{max_breaks} |
0-based index in the series (missing observations included) of the last observation before each break, in chronological order; NaN past n_breaks. |
magnitude_1 … magnitude_{max_breaks} |
The model after each break minus the model before it, both on the first observation after the break (the seasonal terms cancel, the change of level, trend and season shape remains). |
break_date_1 … break_date_{max_breaks} |
Date of the first observation after each break (NaT past n_breaks). |
bfl = zeit.bfast_lite(ndvi, max_breaks=3)
first = bfl.break_date_1 # NaT where n_breaks == 0
loss = zeit.extract_events(bfl, event_type="loss") # the greatest drop of each pixel
bfast¶
zeit.bfast(
data, dates=None, start_time=None, frequency=None, order=3,
h=0.15, max_breaks_trend=5, max_breaks_season=5, max_iter=10,
level=0.05, min_valid=20, band=None, nodata="auto", chunks=None,
n_jobs=-1,
)
Classic iterative BFAST (Verbesselt et al. 2010): an STL seasonal seed, then alternating trend and season segmented regressions (breaks chosen with BIC) until neither changes, each preceded by an OLS-MOSUM stability test. Separates trend breaks from seasonal breaks. Input, time axis and output are as for bfast_monitor; the series must hold more than 2 × frequency observations.
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
path, DataArray, Dataset, ndarray, list or pd.Series |
required | What to decompose (see bfast_monitor). |
dates, start_time, frequency |
None, None, None |
The time axis, as in bfast_monitor. |
|
order |
int |
3 |
Harmonics of the season model. |
h |
float |
0.15 |
Minimum segment size, as a fraction of the valid observations. |
max_breaks_trend, max_breaks_season |
int |
5, 5 |
Trend and season break slots in the output. |
max_iter |
int |
10 |
Maximum trend/season iterations. |
level |
float |
0.05 |
Significance of the stability pre-test (1.0 always searches for breaks). |
min_valid |
int |
20 |
Minimum valid observations. |
band, nodata, chunks, n_jobs |
None, "auto", None, -1 |
As in bfast_monitor. |
Returns an xarray.Dataset of (y, x) float32 maps:
| Variable | Meaning |
|---|---|
n_trend_breaks, n_season_breaks |
Number of trend and season breaks at convergence. |
magnitude |
Largest jump in the trend (R's bf$Magnitude); 0 without a trend break. |
break_time |
Decimal year of that jump (R's bf$jump$x); NaN if none. Formerly the time metric, renamed so it does not clash with the time coordinate. |
n_iter |
Iterations until convergence (or max_iter). |
n_valid |
Valid observations used. |
valid |
1 if the series was long enough to fit. |
trend_breakpoint_idx_1 … _{max_breaks_trend} |
0-based index in the series (missing observations included) of the last observation before each trend break; NaN past n_trend_breaks. |
season_breakpoint_idx_1 … _{max_breaks_season} |
The same for season breaks. |
trend_magnitude_1 … _{max_breaks_trend} |
The jump of the trend at each trend break; magnitude is the largest of them. |
trend_break_date_1 … _{max_breaks_trend} |
Date of the first observation after each trend break. |