Lesson 1: detecting disturbances using COLD and S-CCD
=====================================================
**Author: Su Ye (remotesensingsuy@gmail.com)**
**Time series datasets: Harmonized Landsat-Sentinel (HLS) datasets**
**Application: forest fire in Sichuan, China**
COLD (latest CCDC)
------------------
The COntinuous monitoring of Land Disturbance (COLD) algorithm is the
latest version for Continuous Change Detection and Classification
(CCDC), developed for monitoring land surface dynamics using satellite
imagery, particularly the Landsat archive. COLD was proposed by Zhe et
al (2020), which made several important improvements over the original
CCDC proposed by Zhe et al (2014). COLD/CCDC models the temporal
trajectory of surface reflectance at the pixel level by fitting all
available observations with a set of harmonic regression functions that
capture seasonal variation, along with linear terms to represent
long-term trends. This enables the algorithm to continuously track both
gradual and abrupt land surface changes.
When new observations arrive, COLD/CCDC evaluates whether they deviate
significantly from the modeled trajectory. If a consistent deviation
beyond statistical thresholds is detected, a break is identified,
signaling a potential disturbance or land cover change event
(detection). By recording multiple breaks per pixel, COLD/CCDC supports
monitoring of historical changes, such as forest disturbances, urban
expansion, agricultural rotation, or vegetation recovery. At the same
time, COLD/CCDC extracts the harmonic coefficients for each temporal
segment determined by breaks, which will be used for segment-based
land-cover classification (classification).
References:
*Zhu, Z., Zhang, J., Yang, Z., Aljaddani, A. H., Cohen, W. B., Qiu, S.,
& Zhou, C. (2020). Continuous monitoring of land disturbance based on
Landsat time series. Remote Sensing of Environment, 238, 111116.*
*Zhu, Z., & Woodcock, C. E. (2014). Continuous change detection and
classification of land cover using all available Landsat data. Remote
sensing of Environment, 144, 152-171.*
--------------
COLD Break detection
~~~~~~~~~~~~~~~~~~~~
The Ya’an Fire, one of the most destructive wildfires in China, occurred
in Ya’an County, Sichuan Province, on March 22. Here shows using
``COLD`` to detect a pixel-based HLS time series under the 2024 Ya’an
Fire:
.. code:: ipython3
import numpy as np
import pathlib
import pandas as pd
# Imports from this package
from pyxccd import cold_detect
TUTORIAL_DATASET = (pathlib.Path.cwd() / 'datasets').resolve() # modify it as you need
assert TUTORIAL_DATASET.exists()
in_path = TUTORIAL_DATASET/ '1_hls_sc.csv'
# read example csv for HLS time series
data = pd.read_csv(in_path)
# split the array by the column
dates, blues, greens, reds, nirs, swir1s, swir2s, thermals, qas, sensor = data.to_numpy().copy().T
cold_result = cold_detect(dates, blues, greens, reds, nirs, swir1s, swir2s, thermals, qas)
# convert ordinal date to human readable date
break_date = pd.Timestamp.fromordinal(cold_result[0]["t_break"]).strftime('%Y-%m-%d')
print(f"The break detected is {break_date}")
print("COLD results is: ")
print(cold_result)
.. raw:: html
.. code:: text
:class: output-block
The break detected is 2024-03-23
COLD results is:
[(735600, 738960, 738968, 1, 472, 8, 100, [[ 1.6766739e+02, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00], [ 3.6711215e+02, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00], [ 3.5981775e+02, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00], [-1.8439887e+04, 2.7444632e+02, 0.0000000e+00, 0.0000000e+00, 2.4501804e+01, -2.7643259e+01, 6.1835299e+00, -1.1128180e+01], [ 1.2269283e+03, 0.0000000e+00, 0.0000000e+00, 9.2912989e+00, 0.0000000e+00, -1.4118568e+01, 0.0000000e+00, -5.2788010e+00], [ 7.1484528e+02, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00], [ 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00]], [ 32.981544, 46.93689 , 51.279877, 134.50009 , 138.7891 , 92.00378 , 0. ], [ 220.33261, 170.38785, 256.18225, -920.6151 , 158.78595, 771.6547 , 0. ])
(738968, 739252, 0, 1, 46, 24, 0, [[ 4.3974188e+02, 0.0000000e+00, 7.6601973e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00], [-6.6828550e+03, 9.8554466e+01, 3.9433846e+01, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00], [ 7.4310809e+02, 0.0000000e+00, 6.7782188e+01, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00], [-1.9364056e+05, 2.6346836e+03, 5.6232704e+01, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00], [ 1.6937788e+03, 0.0000000e+00, 1.1827483e+02, 5.3090653e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00], [ 1.6231411e+03, 0.0000000e+00, 1.3118753e+02, 7.0458405e+01, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00], [ 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00, 0.0000000e+00]], [ 70.27479 , 64.3015 , 71.30929 , 87.261406, 123.548836, 113.304276, 0. ], [ 0. , 0. , 0. , 0. , 0. , 0. , 0. ])]
COLD is a per-pixel algorithm, and the fundamental outputs are the
temporal segments of the input data defined by the break. After running
``cold_detect``, the output is a 1-d structural array. The length of the
array denotes the number of the segments for this pixel (for this case,
we got two segments and one break). Each element include 10 attributes
as the following:
- t_start: Ordinal date when series model gets started
- t_end: Ordinal date when series model gets ended
- t_break: Ordinal date when the break is detected (the observation next to t_end)
- pos: Location of each time series model using the date code as user
define. For example, in the lesson 4, pos = i \* n_cols + j, where i is
the 0-based row number, j is the 1-based column number, to guarantee the
pos starts from 1. For a HLS pixel at 1000th row, and 1st col, pos is
3660*1000+1
- num_obs: Number of clear observations used for model
estimation
- category: Quality of the model estimation (what model is
used, what process is used)
::
(first digit)
0 - normal model (no change);
1 - change at the beginning of time series model;
2 - change at the end of time series model;
3 - disturbance change in the middle;
4 - fmask fail scenario;
5 - permanent snow scenario;
6 - outside user mask
(second digit)
1 - model has only constant term;
4 - model has 3 coefs + 1 const;
6 - model has 5 coefs + 1 const;
8 - model has 7 coefs + 1 const;
- change_prob: Probability of a pixel that have undergone change
(between 0 and 100)
- coefs: 2-d array of shape (7, 8) containing multispectral harmonic
coefficients obtained from Lasso regression. Each row corresponds to a
specific spectral band in the following fixed order: blue, green, red,
NIR, SWIR1, SWIR2, and thermal (rows 0 to 6 respectively).
Note:**the slope coefficients (located in the second column of the
array) have been scaled by a factor of 10,000** in pyxccd to optimize
storage efficiency when using float32 precision. Before using these
coefficients for harmonic curve prediction, the slope values must be
restored to their original scale by dividing them by 10,000.
- rmse: 1-d array of shape (7,), multispectral RMSE of predicted and
actiual observations
- magnitude: 1-d array of shape (7,), multispectral median difference
between model prediction and observations of a window of conse
observations following detected breakpoint
COLD break categorization
~~~~~~~~~~~~~~~~~~~~~~~~~
Considering the spectral break is not necessarily linked to the
disturbances, but also possibly related to climate variability,
succession, and even data noise, the COLD algorithm provides a quick
rule-based solution to determine the category of the break
(1-disturbance, 2-regrowth, 3-reafforestation). For more details, please
refers to Section 3.3.7 in the COLD paper (“Continuous monitoring of
land disturbance based on Landsat time series”)
Pyxccd provides this function for determining the break category:
.. code:: ipython3
from pyxccd.utils import getcategory_cold
print(f"The category for the first break is {getcategory_cold(cold_result, 0)}") # 0 means the first break, 1 means the second, etc
.. code:: text
:class: output-block
The category for the first break is 1
COLD Visualization
~~~~~~~~~~~~~~~~~~
Next, we will show how to plot the NIR time series and the COLD break
detection results (note that COLD combines green, red, NIR, swir1, swir2
to determine the break while we only used NIR to exemplify the curve
fitting and break detection):
.. code:: ipython3
from pyxccd.common import cold_rec_cg
from pyxccd.utils import read_data, getcategory_cold
from datetime import date
from typing import List, Tuple, Dict, Union, Optional
import seaborn as sns
import matplotlib.pyplot as plt
from matplotlib.axes import Axes
def display_cold_result(
data: np.ndarray,
band_names: List[str],
band_index: int,
cold_result: cold_rec_cg,
axe: Axes,
title: str = 'COLD',
plot_kwargs: Optional[Dict] = None
) -> Tuple[plt.Figure, List[plt.Axes]]:
"""
Compare COLD and SCCD change detection algorithms by plotting their results side by side.
This function takes time series remote sensing data, applies both COLD algorithms,
and visualizes the curve fitting and break detection results.
Parameters:
-----------
data : np.ndarray
Input data array with shape (n_observations, n_bands + 2) where:
- First column: ordinal dates (days since January 1, AD 1)
- Next n_bands columns: spectral band values
- Last column: QA flags (0-clear, 1-water, 2-shadow, 3-snow, 4-cloud)
band_names : List[str]
List of band names corresponding to the spectral bands in the data (e.g., ['red', 'nir'])
band_index : int
0-based index of the band to plot (e.g., 0 for first band, 1 for second band)
axe: Axes
An Axes object represents a single plot within that Figure
title: Str
The figure title. The default is "COLD"
plot_kwargs : Dict, optional
Additional keyword arguments to pass to the display function. Possible keys:
- 'marker_size': size of observation markers (default: 5)
- 'marker_alpha': transparency of markers (default: 0.7)
- 'line_color': color of model fit lines (default: 'orange')
- 'font_size': base font size (default: 14)
Returns:
--------
Tuple[plt.Figure, List[plt.Axes]]
A tuple containing the matplotlib Figure object and a list of Axes objects
(top axis is COLD results, bottom axis is SCCD results)
"""
w = np.pi * 2 / 365.25
# Set default plot parameters
default_plot_kwargs: Dict[str, Union[int, float, str]] = {
'marker_size': 5,
'marker_alpha': 0.7,
'line_color': 'orange',
'font_size': 14
}
if plot_kwargs is not None:
default_plot_kwargs.update(plot_kwargs)
# Extract values with proper type casting
font_size = default_plot_kwargs.get('font_size', 14)
try:
title_font_size = int(font_size) + 2
except (TypeError, ValueError):
title_font_size = 16
# Clean and prepare data
data = data[np.all(np.isfinite(data), axis=1)]
data_df = pd.DataFrame(data, columns=['dates'] + band_names + ['qa'])
# Plot COLD results
w = np.pi * 2 / 365.25
slope_scale = 10000
# Prepare clean data for COLD plot
data_clean = data_df[(data_df['qa'] == 0) | (data_df['qa'] == 1)].copy()
data_clean = data_clean[(data_clean >= 0).all(axis=1) & (data_clean.drop(columns="dates") <= 10000).all(axis=1)]
calendar_dates = [pd.Timestamp.fromordinal(int(row)) for row in data_clean["dates"]]
data_clean.loc[:, 'dates_formal'] = calendar_dates
# Calculate y-axis limits
band_name = band_names[band_index]
band_values = data_clean[data_clean['qa'] == 0][band_name]
q01, q99 = np.quantile(band_values, [0.01, 0.99])
extra = (q99 - q01) * 0.4
ylim_low = q01 - extra
ylim_high = q99 + extra
# Plot COLD observations
axe.plot(
'dates_formal', band_name, 'go',
markersize=default_plot_kwargs['marker_size'],
alpha=default_plot_kwargs['marker_alpha'],
data=data_clean
)
# Plot COLD segments
for segment in cold_result:
j = np.arange(segment['t_start'], segment['t_end'] + 1, 1)
plot_df = pd.DataFrame({
'dates': j,
'trend': j * segment['coefs'][band_index][1] / slope_scale + segment['coefs'][band_index][0],
'annual': np.cos(w * j) * segment['coefs'][band_index][2] + np.sin(w * j) * segment['coefs'][band_index][3],
'semiannual': np.cos(2 * w * j) * segment['coefs'][band_index][4] + np.sin(2 * w * j) * segment['coefs'][band_index][5],
'trimodel': np.cos(3 * w * j) * segment['coefs'][band_index][6] + np.sin(3 * w * j) * segment['coefs'][band_index ][7]
})
plot_df['predicted'] = (
plot_df['trend'] +
plot_df['annual'] +
plot_df['semiannual'] +
plot_df['trimodel']
)
# Convert dates and plot model fit
calendar_dates = [pd.Timestamp.fromordinal(int(row)) for row in plot_df["dates"]]
plot_df.loc[:, 'dates_formal'] = calendar_dates
g = sns.lineplot(
x="dates_formal", y="predicted",
data=plot_df,
label="Model fit",
ax=axe,
color=default_plot_kwargs['line_color']
)
if g.legend_ is not None:
g.legend_.remove()
# plot breaks
for i in range(len(cold_result)):
if cold_result[i]['change_prob'] == 100:
if getcategory_cold(cold_result, i) == 1:
axe.axvline(pd.Timestamp.fromordinal(cold_result[i]['t_break']), color='k')
else:
axe.axvline(pd.Timestamp.fromordinal(cold_result[i]['t_break']), color='r')
axe.set_ylabel(f"{band_name} * 10000", fontsize=default_plot_kwargs['font_size'])
# Handle tick params with type safety
tick_font_size = default_plot_kwargs['font_size']
if isinstance(tick_font_size, (int, float)):
axe.tick_params(axis='x', labelsize=int(tick_font_size)-1)
else:
axe.tick_params(axis='x', labelsize=13) # fallback
axe.set(ylim=(ylim_low, ylim_high))
axe.set_xlabel("", fontsize=6)
# Format spines
for spine in axe.spines.values():
spine.set_edgecolor('black')
title_font_size = int(font_size) + 2 if isinstance(font_size, (int, float)) else 16
axe.set_title(title, fontweight="bold", size=title_font_size, pad=2)
# Set up plotting style
sns.set_theme(style="darkgrid")
sns.set_context("notebook")
# Create figure and axes
fig, ax = plt.subplots(figsize=(12, 5))
# plt.subplots_adjust(left=0.08, right=0.98, top=0.92, bottom=0.1)
display_cold_result(data=np.stack((dates, blues, greens, reds, nirs, swir1s, swir2s, thermals, qas), axis=1), band_names=['blues', 'green', 'red', 'nir', 'swir1', 'swir2', 'thermals'], band_index=3, cold_result=cold_result, axe=ax, title="Standard COLD")
.. image:: 1_break_detection_fire_hls_files/1_break_detection_fire_hls_6_0.png
Lambda
~~~~~~
You may not be fully satisfied with the current fitting curve (the
yellow line). To address this, we provide the parameter ``lam`` to
control the degree of regularization in Lasso regression. When ``lam`` =
0, all harmonic coefficients are estimated without penalty, and Lasso
reduces to ordinary least squares (OLS) regression. Although this may
produce a visually better fit to the observations, it can increase the
risk of overfitting. For example, in the case shown below, an excessive
break was detected in 2016 before the actual fire disturbance. This
spurious break is most likely a commission error caused by overfitting
in the curve fitting process. The default ``lam`` for COLD/S-CCD is 20,
which was based upon numerous preliminary tests.
.. code:: ipython3
cold_result = cold_detect(dates, blues, greens, reds, nirs, swir1s, swir2s, thermals, qas, lam=0)
# Create figure and axes
fig, ax = plt.subplots(figsize=(12, 5))
# plt.subplots_adjust(left=0.08, right=0.98, top=0.92, bottom=0.1)
display_cold_result(data=np.stack((dates, blues, greens, reds, nirs, swir1s, swir2s, thermals, qas), axis=1), band_names=['blues', 'green', 'red', 'nir', 'swir1', 'swir2', 'thermals'], band_index=3, cold_result=cold_result, axe=ax, title="COLD (lambda=0)")
.. image:: 1_break_detection_fire_hls_files/1_break_detection_fire_hls_8_0.png
S-CCD
-----
Stochastic Continuous Change Detection (S-CCD) is an advanced variant of
the Continuous Change Detection and Classification (CCDC) framework (Ye
et al, 2021), designed to improve the timeliness and interpretation of
land surface change detection. Unlike the original CCDC, which fits
deterministic harmonic and linear models to the entire Landsat or
Harmonized Landsat–Sentinel (HLS) time series, S-CCD introduces a
stochastic updating mechanism that allows the model to evolve
dynamically as new satellite observations arrive.
The key innovation of S-CCD is its use of recursive model updating
(i.e., Kalman filter), which eliminates the need to refit the entire
time series whenever new data are ingested. Instead, model coefficients
(trend and seasonal parameters) are updated incrementally in a
short-memory manner. This design makes the algorithm more
computationally efficient and capable of operating in near real time.
Moreover, S-CCD allows for outputting “states” for time-series
components (annual, seminal, etc), thereby reaching a better capture for
gradual change of seasonality and general trend in addition to break
detection. For the scenario of retrospective time-series analysis, S-CCD
has comparable detection accuracy with COLD.
Reference:
*Ye, S., Rogan, J., Zhu, Z., & Eastman, J. R. (2021). A near-real-time
approach for monitoring forest disturbance using Landsat time series:
Stochastic continuous change detection. Remote Sensing of Environment,
252, 112167.*
--------------
S-CCD Break detection
~~~~~~~~~~~~~~~~~~~~~
The below is using S-CCD for the Ya’an fire site
.. code:: ipython3
from pyxccd import sccd_detect
# note that the standard s-ccd doesn't need thermal band for efficient computation, you could switch sccd_detect_flex which allows you to input any combination of bands if you really want to use thermal
sccd_result = sccd_detect(dates, blues, greens, reds, nirs, swir1s, swir2s, qas)
break_date = pd.Timestamp.fromordinal(sccd_result.rec_cg[0]["t_break"]).strftime('%Y-%m-%d')
print(f"The break detected is {break_date}")
sccd_result
.. code:: text
:class: output-block
The break detected is 2024-03-23
.. code:: text
:class: output-block
SccdOutput(position=1, rec_cg=array([(735600, 738968, 441, [[ 5.7651807e+03, -7.5955360e+01, 2.8375614e-01, 5.1964793e+00, -2.0415826e+00, -6.4547181e+00], [ 1.6891670e+03, -1.8045355e+01, 2.2810047e+00, 1.6642979e+01, -5.6901956e+00, -1.3014506e+01], [ 1.2292332e+04, -1.6212231e+02, 3.5307232e+01, 1.7814684e+01, -1.0739973e+01, -1.8438562e+01], [-2.6667223e+04, 3.8507657e+02, 9.7016243e+01, -3.8088055e+00, 2.9747089e+01, -5.9461620e+01], [ 2.5863348e+04, -3.3480228e+02, 5.9306335e+01, 2.6777798e+01, -1.2760725e+01, -4.4620617e+01], [ 1.5446797e+04, -2.0042662e+02, 3.9952637e+01, 2.0489840e+01, -1.7458494e+01, -2.8435680e+01]], [28.350677, 33.288532, 34.144318, 94.36975 , 91.12302 , 59.044655], [ 231.40686, 157.6067 , 277.8084 , -850.01636, 239.03906, 819.48413])],
dtype={'names': ['t_start', 't_break', 'num_obs', 'coefs', 'rmse', 'magnitude'], 'formats': [' Tuple[plt.Figure, List[plt.Axes]]:
"""
Compare COLD and SCCD change detection algorithms by plotting their results side by side.
This function takes time series remote sensing data, applies both COLD and SCCD algorithms,
and visualizes the curve fitting and break detection results for comparison.
Parameters:
-----------
data : np.ndarray
Input data array with shape (n_observations, n_bands + 2) where:
- First column: ordinal dates (days since January 1, AD 1)
- Next n_bands columns: spectral band values
- Last column: QA flags (0-clear, 1-water, 2-shadow, 3-snow, 4-cloud)
band_names : List[str]
List of band names corresponding to the spectral bands in the data (e.g., ['red', 'nir'])
band_index : int
1-based index of the band to plot (e.g., 0 for first band, 1 for second band)
sccd_result: SccdOutput
Output of sccd_detect
axe: Axes
An Axes object represents a single plot within that Figure
title: Str
The figure title. The default is "S-CCD"
plot_kwargs : Dict, optional
Additional keyword arguments to pass to the display function. Possible keys:
- 'marker_size': size of observation markers (default: 5)
- 'marker_alpha': transparency of markers (default: 0.7)
- 'line_color': color of model fit lines (default: 'orange')
- 'font_size': base font size (default: 14)
Returns:
--------
Tuple[plt.Figure, List[plt.Axes]]
A tuple containing the matplotlib Figure object and a list of Axes objects
(top axis is COLD results, bottom axis is SCCD results)
"""
w = np.pi * 2 / 365.25
# Set default plot parameters
default_plot_kwargs: Dict[str, Union[int, float, str]] = {
'marker_size': 5,
'marker_alpha': 0.7,
'line_color': 'orange',
'font_size': 14
}
if plot_kwargs is not None:
default_plot_kwargs.update(plot_kwargs)
# Extract values with proper type casting
font_size = default_plot_kwargs.get('font_size', 14)
try:
title_font_size = int(font_size) + 2
except (TypeError, ValueError):
title_font_size = 16
# Clean and prepare data
data = data[np.all(np.isfinite(data), axis=1)]
data_df = pd.DataFrame(data, columns=['dates'] + band_names + ['qa'])
# Plot COLD results
w = np.pi * 2 / 365.25
slope_scale = 10000
# Prepare clean data for COLD plot
data_clean = data_df[(data_df['qa'] == 0) | (data_df['qa'] == 1)].copy()
data_clean = data_clean[(data_clean >= 0).all(axis=1) & (data_clean.drop(columns="dates") <= 10000).all(axis=1)]
calendar_dates = [pd.Timestamp.fromordinal(int(row)) for row in data_clean["dates"]]
data_clean.loc[:, 'dates_formal'] = calendar_dates
# Calculate y-axis limits
band_name = band_names[band_index]
band_values = data_clean[data_clean['qa'] == 0 | (data_clean['qa'] == 1)][band_name]
# band_values = band_values[band_values <10000]
q01, q99 = np.quantile(band_values, [0.01, 0.99])
extra = (q99 - q01) * 0.4
ylim_low = q01 - extra
ylim_high = q99 + extra
# Plot SCCD observations
axe.plot(
'dates_formal', band_name, 'go',
markersize=default_plot_kwargs['marker_size'],
alpha=default_plot_kwargs['marker_alpha'],
data=data_clean
)
# Plot SCCD segments
for segment in sccd_result.rec_cg:
j = np.arange(segment['t_start'], segment['t_break'] + 1, 1)
if len(segment['coefs'][band_index]) == 8:
plot_df = pd.DataFrame(
{
'dates': j,
'trend': j * segment['coefs'][band_index][1] / slope_scale + segment['coefs'][band_index][0],
'annual': np.cos(w * j) * segment['coefs'][band_index][2] + np.sin(w * j) * segment['coefs'][band_index][3],
'semiannual': np.cos(2 * w * j) * segment['coefs'][band_index][4] + np.sin(2 * w * j) * segment['coefs'][band_index][5],
'trimodal': np.cos(3 * w * j) * segment['coefs'][band_index][6] + np.sin(3 * w * j) * segment['coefs'][band_index][7]
})
else:
plot_df = pd.DataFrame(
{
'dates': j,
'trend': j * segment['coefs'][band_index][1] / slope_scale + segment['coefs'][band_index][0],
'annual': np.cos(w * j) * segment['coefs'][band_index][2] + np.sin(w * j) * segment['coefs'][band_index][3],
'semiannual': np.cos(2 * w * j) * segment['coefs'][band_index][4] + np.sin(2 * w * j) * segment['coefs'][band_index][5],
'trimodal': j * 0
})
plot_df['predicted'] = (
plot_df['trend'] +
plot_df['annual'] +
plot_df['semiannual']+
plot_df['trimodal']
)
# Convert dates and plot model fit
calendar_dates = [pd.Timestamp.fromordinal(int(row)) for row in plot_df["dates"]]
plot_df.loc[:, 'dates_formal'] = calendar_dates
g = sns.lineplot(
x="dates_formal", y="predicted",
data=plot_df,
label="Model fit",
ax=axe,
color=default_plot_kwargs['line_color']
)
if g.legend_ is not None:
g.legend_.remove()
# Plot near-real-time projection for SCCD if available
if hasattr(sccd_result, 'nrt_mode') and (sccd_result.nrt_mode %10 == 1 or sccd_result.nrt_mode == 3 or sccd_result.nrt_mode %10 == 5):
recent_obs = sccd_result.nrt_model['obs_date_since1982'][sccd_result.nrt_model['obs_date_since1982']>0]
j = np.arange(
sccd_result.nrt_model['t_start_since1982'] + defaults['COMMON']['JULIAN_LANDSAT4_LAUNCH'],
recent_obs[-1]+ defaults['COMMON']['JULIAN_LANDSAT4_LAUNCH']+1,
1
)
if len(sccd_result.nrt_model['nrt_coefs'][band_index]) == 8:
plot_df = pd.DataFrame(
{
'dates': j,
'trend': j * sccd_result.nrt_model['nrt_coefs'][band_index][1] / slope_scale + sccd_result.nrt_model['nrt_coefs'][band_index][0],
'annual': np.cos(w * j) * sccd_result.nrt_model['nrt_coefs'][band_index][2] + np.sin(w * j) * sccd_result.nrt_model['nrt_coefs'][band_index][3],
'semiannual': np.cos(2 * w * j) * sccd_result.nrt_model['nrt_coefs'][band_index][4] + np.sin(2 * w * j) * sccd_result.nrt_model['nrt_coefs'][band_index][5],
'trimodal': np.cos(3 * w * j) * sccd_result.nrt_model['nrt_coefs'][band_index][6] + np.sin(3 * w * j) * sccd_result.nrt_model['nrt_coefs'][band_index][7]
})
else:
plot_df = pd.DataFrame(
{
'dates': j,
'trend': j * sccd_result.nrt_model['nrt_coefs'][band_index][1] / slope_scale + sccd_result.nrt_model['nrt_coefs'][band_index][0],
'annual': np.cos(w * j) * sccd_result.nrt_model['nrt_coefs'][band_index][2] + np.sin(w * j) * sccd_result.nrt_model['nrt_coefs'][band_index][3],
'semiannual': np.cos(2 * w * j) * sccd_result.nrt_model['nrt_coefs'][band_index][4] + np.sin(2 * w * j) * sccd_result.nrt_model['nrt_coefs'][band_index][5],
'trimodal': j * 0
})
plot_df['predicted'] = plot_df['trend'] + plot_df['annual'] + plot_df['semiannual']+ plot_df['trimodal']
calendar_dates = [pd.Timestamp.fromordinal(int(row)) for row in plot_df["dates"]]
plot_df.loc[:, 'dates_formal'] = calendar_dates
g = sns.lineplot(
x="dates_formal", y="predicted",
data=plot_df,
label="Model fit",
ax=axe,
color=default_plot_kwargs['line_color']
)
if g.legend_ is not None:
g.legend_.remove()
# Plot breaks
for i in range(len(sccd_result.rec_cg)):
if getcategory_sccd(sccd_result.rec_cg, i) == 1:
axe.axvline(pd.Timestamp.fromordinal(sccd_result.rec_cg[i]['t_break']), color='k')
else:
axe.axvline(pd.Timestamp.fromordinal(sccd_result.rec_cg[i]['t_break']), color='r')
axe.set_ylabel(f"{band_name} * 10000", fontsize=default_plot_kwargs['font_size'])
# Handle tick params with type safety
tick_font_size = default_plot_kwargs['font_size']
if isinstance(tick_font_size, (int, float)):
axe.tick_params(axis='x', labelsize=int(tick_font_size)-1)
else:
axe.tick_params(axis='x', labelsize=13) # fallback
axe.set(ylim=(ylim_low, ylim_high))
axe.set_xlabel("", fontsize=6)
# Format spines
for spine in axe.spines.values():
spine.set_edgecolor('black')
title_font_size = int(font_size) + 2 if isinstance(font_size, (int, float)) else 16
axe.set_title(title, fontweight="bold", size=title_font_size, pad=2)
sns.set_theme(style="darkgrid")
sns.set_context("notebook")
# Create figure and axes
fig, ax = plt.subplots(figsize=(12, 5))
display_sccd_result(data=np.stack((dates, blues, greens, reds, nirs, swir1s, swir2s, qas), axis=1), band_names=['blues', 'green', 'red', 'nir', 'swir1', 'swir2'], band_index=3, sccd_result=sccd_result, axe=ax)
.. image:: 1_break_detection_fire_hls_files/1_break_detection_fire_hls_12_0.png
From the results, S-CCD yields very similar results as the COLD. For the
last segment, there is no fitting curve, which is because ``nrt_model``
has not been initialized due to not enough observations (<=18) or the
period of observations is less than one year.
OK. So far, you have learned the first class to run basic COLD and S-CCD
algorithms for disturbance detection. What if you couldn’t detect break
if the change is too subtle? The next lesson will lead you to adjust
algorithm parameters to improve sensitivity.