Skip to main content

oxigdal_temporal/compositing/
mod.rs

1//! Temporal Compositing Module
2//!
3//! Implements various temporal compositing methods for creating representative
4//! rasters from time series including median, mean, max NDVI, and quality-weighted composites.
5
6use crate::error::{Result, TemporalError};
7use crate::timeseries::TimeSeriesRaster;
8use scirs2_core::ndarray::Array3;
9use serde::{Deserialize, Serialize};
10use tracing::info;
11
12#[cfg(feature = "parallel")]
13#[allow(unused_imports)]
14use rayon::prelude::*;
15
16pub mod max_ndvi;
17pub mod mean;
18pub mod median;
19
20/// Compositing method
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22pub enum CompositingMethod {
23    /// Median composite (per band)
24    Median,
25    /// Mean composite (per band)
26    Mean,
27    /// Maximum value composite (MVC)
28    Maximum,
29    /// Minimum value composite
30    Minimum,
31    /// Maximum NDVI composite
32    MaxNDVI,
33    /// Quality-weighted composite
34    QualityWeighted,
35    /// First valid value
36    FirstValid,
37    /// Last valid value
38    LastValid,
39}
40
41/// Compositing configuration
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct CompositingConfig {
44    /// Compositing method
45    pub method: CompositingMethod,
46    /// Maximum cloud cover threshold
47    pub max_cloud_cover: Option<f32>,
48    /// Minimum quality score
49    pub min_quality: Option<f32>,
50    /// NoData value
51    pub nodata: Option<f64>,
52    /// Red band index for NDVI (0-based)
53    pub red_band: Option<usize>,
54    /// NIR band index for NDVI (0-based)
55    pub nir_band: Option<usize>,
56}
57
58impl Default for CompositingConfig {
59    fn default() -> Self {
60        Self {
61            method: CompositingMethod::Median,
62            max_cloud_cover: None,
63            min_quality: None,
64            nodata: Some(f64::NAN),
65            red_band: Some(0),
66            nir_band: Some(1),
67        }
68    }
69}
70
71/// Composite result
72#[derive(Debug, Clone)]
73pub struct CompositeResult {
74    /// Composited raster data
75    pub data: Array3<f64>,
76    /// Number of valid observations per pixel
77    pub count: Array3<usize>,
78    /// Quality scores (if applicable)
79    pub quality: Option<Array3<f64>>,
80}
81
82impl CompositeResult {
83    /// Create new composite result
84    #[must_use]
85    pub fn new(data: Array3<f64>, count: Array3<usize>) -> Self {
86        Self {
87            data,
88            count,
89            quality: None,
90        }
91    }
92
93    /// Add quality scores
94    #[must_use]
95    pub fn with_quality(mut self, quality: Array3<f64>) -> Self {
96        self.quality = Some(quality);
97        self
98    }
99}
100
101/// Temporal compositor
102pub struct TemporalCompositor;
103
104impl TemporalCompositor {
105    /// Create temporal composite
106    ///
107    /// # Errors
108    /// Returns error if compositing fails
109    pub fn composite(ts: &TimeSeriesRaster, config: &CompositingConfig) -> Result<CompositeResult> {
110        match config.method {
111            CompositingMethod::Median => Self::median_composite(ts, config),
112            CompositingMethod::Mean => Self::mean_composite(ts, config),
113            CompositingMethod::Maximum => Self::max_composite(ts, config),
114            CompositingMethod::Minimum => Self::min_composite(ts, config),
115            CompositingMethod::MaxNDVI => Self::max_ndvi_composite(ts, config),
116            CompositingMethod::QualityWeighted => Self::quality_weighted_composite(ts, config),
117            CompositingMethod::FirstValid => Self::first_valid_composite(ts, config),
118            CompositingMethod::LastValid => Self::last_valid_composite(ts, config),
119        }
120    }
121
122    /// Median composite
123    fn median_composite(
124        ts: &TimeSeriesRaster,
125        config: &CompositingConfig,
126    ) -> Result<CompositeResult> {
127        if ts.is_empty() {
128            return Err(TemporalError::insufficient_data("Empty time series"));
129        }
130
131        let (height, width, n_bands) = ts
132            .expected_shape()
133            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
134
135        let mut composite = Array3::zeros((height, width, n_bands));
136        let mut count = Array3::zeros((height, width, n_bands));
137
138        for i in 0..height {
139            for j in 0..width {
140                for k in 0..n_bands {
141                    let mut values = Vec::new();
142
143                    for entry in ts.entries().values() {
144                        // Apply filters
145                        if let Some(max_cc) = config.max_cloud_cover {
146                            if let Some(cc) = entry.metadata.cloud_cover {
147                                if cc > max_cc {
148                                    continue;
149                                }
150                            }
151                        }
152
153                        if let Some(data) = &entry.data {
154                            let value = data[[i, j, k]];
155                            if let Some(nodata) = config.nodata {
156                                if !value.is_nan() && value != nodata {
157                                    values.push(value);
158                                }
159                            } else if !value.is_nan() {
160                                values.push(value);
161                            }
162                        }
163                    }
164
165                    if !values.is_empty() {
166                        values
167                            .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
168                        let median = if values.len() % 2 == 0 {
169                            (values[values.len() / 2 - 1] + values[values.len() / 2]) / 2.0
170                        } else {
171                            values[values.len() / 2]
172                        };
173                        composite[[i, j, k]] = median;
174                        count[[i, j, k]] = values.len();
175                    }
176                }
177            }
178        }
179
180        info!("Created median composite");
181        Ok(CompositeResult::new(composite, count))
182    }
183
184    /// Mean composite
185    fn mean_composite(
186        ts: &TimeSeriesRaster,
187        config: &CompositingConfig,
188    ) -> Result<CompositeResult> {
189        if ts.is_empty() {
190            return Err(TemporalError::insufficient_data("Empty time series"));
191        }
192
193        let (height, width, n_bands) = ts
194            .expected_shape()
195            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
196
197        let mut composite = Array3::zeros((height, width, n_bands));
198        let mut count = Array3::zeros((height, width, n_bands));
199
200        for i in 0..height {
201            for j in 0..width {
202                for k in 0..n_bands {
203                    let mut sum = 0.0;
204                    let mut n = 0;
205
206                    for entry in ts.entries().values() {
207                        if let Some(max_cc) = config.max_cloud_cover {
208                            if let Some(cc) = entry.metadata.cloud_cover {
209                                if cc > max_cc {
210                                    continue;
211                                }
212                            }
213                        }
214
215                        if let Some(data) = &entry.data {
216                            let value = data[[i, j, k]];
217                            if let Some(nodata) = config.nodata {
218                                if !value.is_nan() && value != nodata {
219                                    sum += value;
220                                    n += 1;
221                                }
222                            } else if !value.is_nan() {
223                                sum += value;
224                                n += 1;
225                            }
226                        }
227                    }
228
229                    if n > 0 {
230                        composite[[i, j, k]] = sum / n as f64;
231                        count[[i, j, k]] = n;
232                    }
233                }
234            }
235        }
236
237        info!("Created mean composite");
238        Ok(CompositeResult::new(composite, count))
239    }
240
241    /// Maximum value composite
242    fn max_composite(
243        ts: &TimeSeriesRaster,
244        _config: &CompositingConfig,
245    ) -> Result<CompositeResult> {
246        if ts.is_empty() {
247            return Err(TemporalError::insufficient_data("Empty time series"));
248        }
249
250        let (height, width, n_bands) = ts
251            .expected_shape()
252            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
253
254        let mut composite = Array3::from_elem((height, width, n_bands), f64::NEG_INFINITY);
255        let mut count = Array3::zeros((height, width, n_bands));
256
257        for i in 0..height {
258            for j in 0..width {
259                for k in 0..n_bands {
260                    for entry in ts.entries().values() {
261                        if let Some(data) = &entry.data {
262                            let value = data[[i, j, k]];
263                            if !value.is_nan() {
264                                if value > composite[[i, j, k]] {
265                                    composite[[i, j, k]] = value;
266                                }
267                                count[[i, j, k]] += 1;
268                            }
269                        }
270                    }
271                }
272            }
273        }
274
275        info!("Created maximum value composite");
276        Ok(CompositeResult::new(composite, count))
277    }
278
279    /// Minimum value composite
280    fn min_composite(
281        ts: &TimeSeriesRaster,
282        _config: &CompositingConfig,
283    ) -> Result<CompositeResult> {
284        if ts.is_empty() {
285            return Err(TemporalError::insufficient_data("Empty time series"));
286        }
287
288        let (height, width, n_bands) = ts
289            .expected_shape()
290            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
291
292        let mut composite = Array3::from_elem((height, width, n_bands), f64::INFINITY);
293        let mut count = Array3::zeros((height, width, n_bands));
294
295        for i in 0..height {
296            for j in 0..width {
297                for k in 0..n_bands {
298                    for entry in ts.entries().values() {
299                        if let Some(data) = &entry.data {
300                            let value = data[[i, j, k]];
301                            if !value.is_nan() {
302                                if value < composite[[i, j, k]] {
303                                    composite[[i, j, k]] = value;
304                                }
305                                count[[i, j, k]] += 1;
306                            }
307                        }
308                    }
309                }
310            }
311        }
312
313        info!("Created minimum value composite");
314        Ok(CompositeResult::new(composite, count))
315    }
316
317    /// Maximum NDVI composite
318    fn max_ndvi_composite(
319        ts: &TimeSeriesRaster,
320        config: &CompositingConfig,
321    ) -> Result<CompositeResult> {
322        let red_band = config.red_band.ok_or_else(|| {
323            TemporalError::invalid_parameter("red_band", "required for MaxNDVI composite")
324        })?;
325
326        let nir_band = config.nir_band.ok_or_else(|| {
327            TemporalError::invalid_parameter("nir_band", "required for MaxNDVI composite")
328        })?;
329
330        if ts.is_empty() {
331            return Err(TemporalError::insufficient_data("Empty time series"));
332        }
333
334        let (height, width, n_bands) = ts
335            .expected_shape()
336            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
337
338        if red_band >= n_bands || nir_band >= n_bands {
339            return Err(TemporalError::invalid_parameter(
340                "band_indices",
341                "band indices out of range",
342            ));
343        }
344
345        let mut composite = Array3::zeros((height, width, n_bands));
346        let mut count = Array3::zeros((height, width, n_bands));
347        let mut max_ndvi = Array3::from_elem((height, width, 1), f64::NEG_INFINITY);
348
349        for entry in ts.entries().values() {
350            if let Some(data) = &entry.data {
351                for i in 0..height {
352                    for j in 0..width {
353                        let red = data[[i, j, red_band]];
354                        let nir = data[[i, j, nir_band]];
355
356                        if !red.is_nan() && !nir.is_nan() && (red + nir) != 0.0 {
357                            let ndvi = (nir - red) / (nir + red);
358
359                            if ndvi > max_ndvi[[i, j, 0]] {
360                                max_ndvi[[i, j, 0]] = ndvi;
361                                // Copy all bands from this observation
362                                for k in 0..n_bands {
363                                    composite[[i, j, k]] = data[[i, j, k]];
364                                }
365                                count[[i, j, 0]] += 1;
366                            }
367                        }
368                    }
369                }
370            }
371        }
372
373        info!("Created maximum NDVI composite");
374        Ok(CompositeResult::new(composite, count))
375    }
376
377    /// Quality-weighted composite
378    fn quality_weighted_composite(
379        ts: &TimeSeriesRaster,
380        _config: &CompositingConfig,
381    ) -> Result<CompositeResult> {
382        if ts.is_empty() {
383            return Err(TemporalError::insufficient_data("Empty time series"));
384        }
385
386        let (height, width, n_bands) = ts
387            .expected_shape()
388            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
389
390        let mut composite: Array3<f64> = Array3::zeros((height, width, n_bands));
391        let mut count: Array3<usize> = Array3::zeros((height, width, n_bands));
392        let mut weight_sum: Array3<f64> = Array3::zeros((height, width, n_bands));
393
394        for entry in ts.entries().values() {
395            let weight = entry.metadata.quality_score.unwrap_or(1.0) as f64;
396
397            if let Some(data) = &entry.data {
398                for i in 0..height {
399                    for j in 0..width {
400                        for k in 0..n_bands {
401                            let value = data[[i, j, k]];
402                            if !value.is_nan() {
403                                composite[[i, j, k]] += value * weight;
404                                weight_sum[[i, j, k]] += weight;
405                                count[[i, j, k]] += 1;
406                            }
407                        }
408                    }
409                }
410            }
411        }
412
413        // Normalize by weights
414        for i in 0..height {
415            for j in 0..width {
416                for k in 0..n_bands {
417                    if weight_sum[[i, j, k]] > 0.0 {
418                        composite[[i, j, k]] /= weight_sum[[i, j, k]];
419                    }
420                }
421            }
422        }
423
424        info!("Created quality-weighted composite");
425        Ok(CompositeResult::new(composite, count))
426    }
427
428    /// First valid value composite
429    fn first_valid_composite(
430        ts: &TimeSeriesRaster,
431        _config: &CompositingConfig,
432    ) -> Result<CompositeResult> {
433        if ts.is_empty() {
434            return Err(TemporalError::insufficient_data("Empty time series"));
435        }
436
437        let (height, width, n_bands) = ts
438            .expected_shape()
439            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
440
441        let mut composite = Array3::zeros((height, width, n_bands));
442        let mut count = Array3::zeros((height, width, n_bands));
443        let mut filled = Array3::from_elem((height, width, n_bands), false);
444
445        for entry in ts.entries().values() {
446            if let Some(data) = &entry.data {
447                for i in 0..height {
448                    for j in 0..width {
449                        for k in 0..n_bands {
450                            if !filled[[i, j, k]] {
451                                let value = data[[i, j, k]];
452                                if !value.is_nan() {
453                                    composite[[i, j, k]] = value;
454                                    count[[i, j, k]] = 1;
455                                    filled[[i, j, k]] = true;
456                                }
457                            }
458                        }
459                    }
460                }
461            }
462        }
463
464        info!("Created first valid value composite");
465        Ok(CompositeResult::new(composite, count))
466    }
467
468    /// Last valid value composite
469    fn last_valid_composite(
470        ts: &TimeSeriesRaster,
471        _config: &CompositingConfig,
472    ) -> Result<CompositeResult> {
473        if ts.is_empty() {
474            return Err(TemporalError::insufficient_data("Empty time series"));
475        }
476
477        let (height, width, n_bands) = ts
478            .expected_shape()
479            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
480
481        let mut composite = Array3::zeros((height, width, n_bands));
482        let mut count = Array3::zeros((height, width, n_bands));
483
484        for entry in ts.entries().values() {
485            if let Some(data) = &entry.data {
486                for i in 0..height {
487                    for j in 0..width {
488                        for k in 0..n_bands {
489                            let value = data[[i, j, k]];
490                            if !value.is_nan() {
491                                composite[[i, j, k]] = value;
492                                count[[i, j, k]] = 1;
493                            }
494                        }
495                    }
496                }
497            }
498        }
499
500        info!("Created last valid value composite");
501        Ok(CompositeResult::new(composite, count))
502    }
503}