Skip to content

Commit f90e208

Browse files
authored
Merge pull request #74 from BnJam/bfast-monitor-impl-10071568848564471051
Implement BFAST Monitor Workflow
2 parents 1950f68 + 19ee114 commit f90e208

7 files changed

Lines changed: 275 additions & 114 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ thiserror = "1.0"
2121
serde = { version = "1.0.228", features = ["derive"] }
2222
serde_json = "1.0.145"
2323
rand = "0.8"
24+
nalgebra = "0.32"
2425

2526
[dev-dependencies]
2627
approx = "0.5"

python/eo_processor/__init__.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,25 @@ def random_forest_predict(model_json, features):
165165

166166

167167
def bfast_monitor(
168-
stack, dates, history_start_date, monitor_start_date, level
168+
stack,
169+
dates,
170+
history_start_date,
171+
monitor_start_date,
172+
order=1,
173+
h=0.25,
174+
alpha=0.05,
169175
):
170176
"""
171-
Scaffold for the BFAST Monitor workflow.
177+
BFAST Monitor workflow for change detection.
172178
"""
173179
return _bfast_monitor(
174-
stack, dates, history_start_date, monitor_start_date, level
180+
stack,
181+
dates,
182+
history_start_date,
183+
monitor_start_date,
184+
order,
185+
h,
186+
alpha,
175187
)
176188

177189

python/eo_processor/__init__.pyi

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,15 @@ def binary_closing(
161161
input: NDArray[np.uint8], kernel_size: int = ...
162162
) -> NDArray[np.uint8]: ...
163163

164+
# Workflows
165+
def bfast_monitor(
166+
stack: NumericArray,
167+
dates: Sequence[int],
168+
history_start_date: int,
169+
monitor_start_date: int,
170+
order: int = ...,
171+
h: float = ...,
172+
alpha: float = ...,
173+
) -> NDArray[np.float64]: ...
174+
164175
# Raises ValueError if p < 1.0

src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,16 @@ pub enum CoreError {
2020
InvalidArgument(String),
2121
#[error("Computation error: {0}")]
2222
ComputationError(String),
23+
#[error("Not enough data: {0}")]
24+
NotEnoughData(String),
2325
}
2426

2527
impl From<CoreError> for PyErr {
2628
fn from(err: CoreError) -> PyErr {
2729
match err {
2830
CoreError::InvalidArgument(msg) => PyValueError::new_err(msg),
2931
CoreError::ComputationError(msg) => PyValueError::new_err(msg),
32+
CoreError::NotEnoughData(msg) => PyValueError::new_err(msg),
3033
}
3134
}
3235
}

src/workflows.rs

Lines changed: 178 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,172 @@
11
use crate::CoreError;
2+
use nalgebra::{DMatrix, DVector};
23
use ndarray::{Axis, IxDyn, Zip};
34
use numpy::{IntoPyArray, PyArrayDyn, PyReadonlyArrayDyn};
45
use pyo3::prelude::*;
56
use rayon::prelude::*;
67

8+
const TWO_PI: f64 = 2.0 * std::f64::consts::PI;
9+
710
// --- 1. BFAST Monitor Workflow ---
811

9-
// Placeholder struct for model parameters
12+
/// Represents the fitted harmonic model.
1013
struct HarmonicModel {
11-
mean: f64,
14+
coefficients: DVector<f64>,
15+
sigma: f64,
1216
}
1317

14-
// Placeholder for fitting a harmonic model to the stable history period.
15-
// In a real implementation, this would involve solving for harmonic coefficients.
16-
fn fit_harmonic_model(y: &[f64]) -> HarmonicModel {
17-
if y.is_empty() {
18-
return HarmonicModel { mean: 0.0 };
18+
/// Constructs the design matrix for a harmonic model.
19+
///
20+
/// # Arguments
21+
///
22+
/// * `dates` - A slice of fractional years.
23+
/// * `order` - The order of the harmonic model (e.g., 1 for one sine/cosine pair).
24+
///
25+
/// # Returns
26+
///
27+
/// A 2D array representing the design matrix `X`.
28+
fn build_design_matrix(dates: &[f64], order: usize) -> DMatrix<f64> {
29+
let n = dates.len();
30+
let num_coeffs = 2 * order + 2; // intercept, trend, and sin/cos pairs
31+
let mut x = DMatrix::<f64>::zeros(n, num_coeffs);
32+
33+
for i in 0..n {
34+
let t = dates[i];
35+
x[(i, 0)] = 1.0; // Intercept
36+
x[(i, 1)] = t; // Trend
37+
for j in 1..=order {
38+
let freq = TWO_PI * j as f64 * t;
39+
x[(i, 2 * j)] = freq.cos();
40+
x[(i, 2 * j + 1)] = freq.sin();
41+
}
1942
}
20-
let sum: f64 = y.iter().sum();
21-
HarmonicModel {
22-
mean: sum / y.len() as f64,
43+
x
44+
}
45+
46+
/// Fits a harmonic model to the stable history period using Ordinary Least Squares (OLS).
47+
fn fit_harmonic_model(y: &[f64], dates: &[f64], order: usize) -> Result<HarmonicModel, CoreError> {
48+
if y.len() < (2 * order + 2) {
49+
return Err(CoreError::NotEnoughData(
50+
"Not enough historical data to fit model".to_string(),
51+
));
2352
}
53+
54+
let y_vec = DVector::from_vec(y.to_vec());
55+
let x = build_design_matrix(dates, order);
56+
57+
let decomp = x.clone().svd(true, true);
58+
let coeffs = decomp.solve(&y_vec, 1e-10).map_err(|e| {
59+
CoreError::ComputationError(format!("Failed to solve OLS with nalgebra: {}", e))
60+
})?;
61+
62+
let y_pred = &x * &coeffs;
63+
let residuals = &y_vec - &y_pred;
64+
let sum_sq_err = residuals.iter().map(|&r| r * r).sum::<f64>();
65+
let df = (y.len() - (2 * order + 2)) as f64;
66+
if df <= 0.0 {
67+
return Err(CoreError::ComputationError(
68+
"Degrees of freedom is non-positive".to_string(),
69+
));
70+
}
71+
let sigma = (sum_sq_err / df).sqrt();
72+
73+
Ok(HarmonicModel {
74+
coefficients: coeffs,
75+
sigma,
76+
})
2477
}
2578

26-
// Placeholder for predicting values based on the fitted model.
27-
fn predict_harmonic_model(_model: &HarmonicModel, dates: &[i64]) -> Vec<f64> {
28-
// For now, just return a constant prediction (the historical mean)
29-
vec![_model.mean; dates.len()]
79+
/// Predicts values for the monitoring period based on the fitted model.
80+
fn predict_harmonic_model(model: &HarmonicModel, dates: &[f64], order: usize) -> DVector<f64> {
81+
let x_mon = build_design_matrix(dates, order);
82+
&x_mon * &model.coefficients
3083
}
3184

32-
// Placeholder for the MOSUM process to detect a break.
33-
// Returns (break_date, magnitude)
85+
/// Detects a break using the OLS-MOSUM process.
3486
fn detect_mosum_break(
3587
y_monitor: &[f64],
36-
y_pred: &[f64],
37-
monitor_dates: &[i64],
38-
level: f64,
88+
y_pred: &DVector<f64>,
89+
monitor_dates: &[f64],
90+
hist_len: usize,
91+
sigma: f64,
92+
h: f64,
93+
alpha: f64,
3994
) -> (f64, f64) {
40-
if y_monitor.is_empty() || y_monitor.len() != y_pred.len() {
95+
if y_monitor.is_empty() {
4196
return (0.0, 0.0);
4297
}
4398

99+
let n_hist = hist_len as f64;
100+
let window_size = (h * n_hist).floor() as usize;
101+
44102
let residuals: Vec<f64> = y_monitor
45103
.iter()
46104
.zip(y_pred.iter())
47105
.map(|(obs, pred)| obs - pred)
48106
.collect();
49107

50-
let mean_residual: f64 = residuals.iter().sum::<f64>() / residuals.len() as f64;
108+
let mut cusum = vec![0.0; residuals.len() + 1];
109+
for i in 0..residuals.len() {
110+
cusum[i + 1] = cusum[i] + residuals[i];
111+
}
112+
113+
// We can only start calculating MOSUM after `window_size` observations
114+
if residuals.len() < window_size {
115+
return (0.0, 0.0);
116+
}
117+
118+
let mosum_process: Vec<f64> = (window_size..residuals.len())
119+
.map(|i| cusum[i] - cusum[i - window_size])
120+
.collect();
121+
122+
let standardizer = sigma * n_hist.sqrt();
123+
let standardized_mosum: Vec<f64> = mosum_process
124+
.iter()
125+
.map(|&m| (m / standardizer).abs())
126+
.collect();
127+
128+
// Simplified critical boundary based on a lookup for alpha=0.05 and h=0.25
129+
// A full implementation would use a precomputed table or a more complex calculation.
130+
let critical_value = if alpha <= 0.05 { 1.36 } else { 1.63 }; // Approximations
51131

52-
// Simplified break detection: if the average residual in the monitoring period
53-
// exceeds the significance level, flag the start of the period as a break.
54-
if mean_residual.abs() > level {
55-
(monitor_dates[0] as f64, mean_residual.abs())
56-
} else {
57-
(0.0, 0.0) // No break detected
132+
for (i, &mosum_val) in standardized_mosum.iter().enumerate() {
133+
// The index k starts from 1 for the monitoring period
134+
let k = (i + 1) as f64;
135+
let boundary = critical_value * (1.0 + k / n_hist).sqrt();
136+
137+
if mosum_val > boundary {
138+
let break_idx = i + window_size;
139+
let magnitude = (y_monitor[break_idx] - y_pred[break_idx]).abs();
140+
return (monitor_dates[break_idx], magnitude);
141+
}
58142
}
143+
144+
(0.0, 0.0) // No break detected
145+
}
146+
147+
/// Converts integer dates (YYYYMMDD) to fractional years.
148+
fn dates_to_frac_years(dates: &[i64]) -> Vec<f64> {
149+
dates
150+
.iter()
151+
.map(|&date| {
152+
let year = (date / 10000) as f64;
153+
let month = ((date % 10000) / 100) as f64;
154+
let day = (date % 100) as f64;
155+
// Simple approximation
156+
year + (month - 1.0) / 12.0 + (day - 1.0) / 365.25
157+
})
158+
.collect()
59159
}
60160

61161
// This is the main logic function that runs for each pixel.
62162
fn run_bfast_monitor_per_pixel(
63163
pixel_ts: &[f64],
64-
dates: &[i64],
65-
history_start: i64,
66-
monitor_start: i64,
67-
level: f64,
164+
dates: &[f64],
165+
history_start: f64,
166+
monitor_start: f64,
167+
order: usize,
168+
h: f64, // h parameter for MOSUM window size
169+
alpha: f64, // Significance level
68170
) -> (f64, f64) {
69171
// 1. Find the indices for the history and monitoring periods
70172
let history_indices: Vec<usize> = dates
@@ -82,32 +184,48 @@ fn run_bfast_monitor_per_pixel(
82184
.collect();
83185

84186
if history_indices.is_empty() || monitor_indices.is_empty() {
85-
return (0.0, 0.0); // Not enough data
187+
return (0.0, 0.0);
86188
}
87189

88190
// 2. Extract the data for these periods
89191
let history_ts: Vec<f64> = history_indices.iter().map(|&i| pixel_ts[i]).collect();
192+
let history_dates: Vec<f64> = history_indices.iter().map(|&i| dates[i]).collect();
90193
let monitor_ts: Vec<f64> = monitor_indices.iter().map(|&i| pixel_ts[i]).collect();
91-
let monitor_dates: Vec<i64> = monitor_indices.iter().map(|&i| dates[i]).collect();
194+
let monitor_dates: Vec<f64> = monitor_indices.iter().map(|&i| dates[i]).collect();
92195

93196
// 3. Fit model on the historical period
94-
let model = fit_harmonic_model(&history_ts);
197+
let model_result = fit_harmonic_model(&history_ts, &history_dates, order);
198+
let model = match model_result {
199+
Ok(m) => m,
200+
Err(_) => return (0.0, 0.0), // Return no-break if model fails
201+
};
95202

96203
// 4. Predict for the monitoring period
97-
let predicted_ts = predict_harmonic_model(&model, &monitor_dates);
204+
let predicted_ts = predict_harmonic_model(&model, &monitor_dates, order);
98205

99206
// 5. Detect break using MOSUM process on residuals
100-
detect_mosum_break(&monitor_ts, &predicted_ts, &monitor_dates, level)
207+
detect_mosum_break(
208+
&monitor_ts,
209+
&predicted_ts,
210+
&monitor_dates,
211+
history_ts.len(),
212+
model.sigma,
213+
h,
214+
alpha,
215+
)
101216
}
102217

103218
#[pyfunction]
219+
#[allow(clippy::too_many_arguments)]
104220
pub fn bfast_monitor(
105221
py: Python,
106222
stack: PyReadonlyArrayDyn<f64>,
107223
dates: Vec<i64>,
108224
history_start_date: i64,
109225
monitor_start_date: i64,
110-
level: f64, // Significance level
226+
order: usize,
227+
h: f64,
228+
alpha: f64,
111229
) -> PyResult<Py<PyArrayDyn<f64>>> {
112230
let stack_arr = stack.as_array();
113231

@@ -132,6 +250,11 @@ pub fn bfast_monitor(
132250
.into());
133251
}
134252

253+
// Convert integer dates to fractional years for modeling
254+
let frac_dates = dates_to_frac_years(&dates);
255+
let history_start_frac = dates_to_frac_years(&[history_start_date])[0];
256+
let monitor_start_frac = dates_to_frac_years(&[monitor_start_date])[0];
257+
135258
// Output channels: [break_date, magnitude]
136259
let mut out_array = ndarray::ArrayD::<f64>::zeros(IxDyn(&[2, height, width]));
137260

@@ -158,10 +281,12 @@ pub fn bfast_monitor(
158281
.par_for_each(|break_date, magnitude, pixel_ts| {
159282
let (bk_date, mag) = run_bfast_monitor_per_pixel(
160283
pixel_ts.as_slice().unwrap(),
161-
&dates,
162-
history_start_date,
163-
monitor_start_date,
164-
level,
284+
&frac_dates,
285+
history_start_frac,
286+
monitor_start_frac,
287+
order,
288+
h,
289+
alpha,
165290
);
166291
*break_date = bk_date;
167292
*magnitude = mag;
@@ -194,16 +319,18 @@ pub fn complex_classification(
194319

195320
let mut out = ndarray::ArrayD::<u8>::zeros(blue_arr.raw_dim());
196321

197-
out.indexed_iter_mut().par_bridge().for_each(|(idx, res)| {
198-
let b = blue_arr[&idx];
199-
let g = green_arr[&idx];
200-
let r = red_arr[&idx];
201-
let n = nir_arr[&idx];
202-
let s1 = swir1_arr[&idx];
203-
let s2 = swir2_arr[&idx];
204-
let t = temp_arr[&idx];
205-
*res = classify_pixel(b, g, r, n, s1, s2, t);
206-
});
322+
out.indexed_iter_mut()
323+
.par_bridge()
324+
.for_each(|(idx, res)| {
325+
let b = blue_arr[&idx];
326+
let g = green_arr[&idx];
327+
let r = red_arr[&idx];
328+
let n = nir_arr[&idx];
329+
let s1 = swir1_arr[&idx];
330+
let s2 = swir2_arr[&idx];
331+
let t = temp_arr[&idx];
332+
*res = classify_pixel(b, g, r, n, s1, s2, t);
333+
});
207334

208335
Ok(out.into_pyarray(py).to_owned())
209336
}

0 commit comments

Comments
 (0)