Skip to main content

oxigeo_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::{TemporalRasterEntry, 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/// Returns `true` if an entry passes the configured cloud-cover filter.
102///
103/// Entries with no recorded cloud cover, or when no threshold is configured,
104/// always pass.
105fn entry_passes_cloud_filter(entry: &TemporalRasterEntry, config: &CompositingConfig) -> bool {
106    match (config.max_cloud_cover, entry.metadata.cloud_cover) {
107        (Some(max_cc), Some(cc)) => cc <= max_cc,
108        _ => true,
109    }
110}
111
112/// Returns `true` if `value` is a valid (non-NaN, non-nodata) sample under the
113/// configured `nodata` value.
114fn value_is_valid(value: f64, config: &CompositingConfig) -> bool {
115    if value.is_nan() {
116        return false;
117    }
118    match config.nodata {
119        Some(nodata) if !nodata.is_nan() => value != nodata,
120        _ => true,
121    }
122}
123
124/// Replaces any pixel that received zero valid observations (still holding the
125/// `±infinity` seed used by max/min reductions) with the configured `nodata`
126/// value, or `NaN` if none is configured, so downstream consumers never see a
127/// raw infinity sentinel.
128fn replace_empty_pixels(
129    composite: &mut Array3<f64>,
130    count: &Array3<usize>,
131    config: &CompositingConfig,
132) {
133    let fill = config.nodata.unwrap_or(f64::NAN);
134    let (h, w, b) = composite.dim();
135    for i in 0..h {
136        for j in 0..w {
137            for k in 0..b {
138                if count[[i, j, k]] == 0 {
139                    composite[[i, j, k]] = fill;
140                }
141            }
142        }
143    }
144}
145
146/// Temporal compositor
147pub struct TemporalCompositor;
148
149impl TemporalCompositor {
150    /// Create temporal composite
151    ///
152    /// # Errors
153    /// Returns error if compositing fails
154    pub fn composite(ts: &TimeSeriesRaster, config: &CompositingConfig) -> Result<CompositeResult> {
155        match config.method {
156            CompositingMethod::Median => Self::median_composite(ts, config),
157            CompositingMethod::Mean => Self::mean_composite(ts, config),
158            CompositingMethod::Maximum => Self::max_composite(ts, config),
159            CompositingMethod::Minimum => Self::min_composite(ts, config),
160            CompositingMethod::MaxNDVI => Self::max_ndvi_composite(ts, config),
161            CompositingMethod::QualityWeighted => Self::quality_weighted_composite(ts, config),
162            CompositingMethod::FirstValid => Self::first_valid_composite(ts, config),
163            CompositingMethod::LastValid => Self::last_valid_composite(ts, config),
164        }
165    }
166
167    /// Median composite
168    fn median_composite(
169        ts: &TimeSeriesRaster,
170        config: &CompositingConfig,
171    ) -> Result<CompositeResult> {
172        if ts.is_empty() {
173            return Err(TemporalError::insufficient_data("Empty time series"));
174        }
175
176        let (height, width, n_bands) = ts
177            .expected_shape()
178            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
179
180        let mut composite = Array3::zeros((height, width, n_bands));
181        let mut count = Array3::zeros((height, width, n_bands));
182
183        for i in 0..height {
184            for j in 0..width {
185                for k in 0..n_bands {
186                    let mut values = Vec::new();
187
188                    for entry in ts.entries().values() {
189                        // Apply filters
190                        if let Some(max_cc) = config.max_cloud_cover
191                            && let Some(cc) = entry.metadata.cloud_cover
192                            && cc > max_cc
193                        {
194                            continue;
195                        }
196
197                        if let Some(data) = &entry.data {
198                            let value = data[[i, j, k]];
199                            if let Some(nodata) = config.nodata {
200                                if !value.is_nan() && value != nodata {
201                                    values.push(value);
202                                }
203                            } else if !value.is_nan() {
204                                values.push(value);
205                            }
206                        }
207                    }
208
209                    if !values.is_empty() {
210                        values
211                            .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
212                        let median = if values.len() % 2 == 0 {
213                            (values[values.len() / 2 - 1] + values[values.len() / 2]) / 2.0
214                        } else {
215                            values[values.len() / 2]
216                        };
217                        composite[[i, j, k]] = median;
218                        count[[i, j, k]] = values.len();
219                    }
220                }
221            }
222        }
223
224        info!("Created median composite");
225        Ok(CompositeResult::new(composite, count))
226    }
227
228    /// Mean composite
229    fn mean_composite(
230        ts: &TimeSeriesRaster,
231        config: &CompositingConfig,
232    ) -> Result<CompositeResult> {
233        if ts.is_empty() {
234            return Err(TemporalError::insufficient_data("Empty time series"));
235        }
236
237        let (height, width, n_bands) = ts
238            .expected_shape()
239            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
240
241        let mut composite = Array3::zeros((height, width, n_bands));
242        let mut count = Array3::zeros((height, width, n_bands));
243
244        for i in 0..height {
245            for j in 0..width {
246                for k in 0..n_bands {
247                    let mut sum = 0.0;
248                    let mut n = 0;
249
250                    for entry in ts.entries().values() {
251                        if let Some(max_cc) = config.max_cloud_cover
252                            && let Some(cc) = entry.metadata.cloud_cover
253                            && cc > max_cc
254                        {
255                            continue;
256                        }
257
258                        if let Some(data) = &entry.data {
259                            let value = data[[i, j, k]];
260                            if let Some(nodata) = config.nodata {
261                                if !value.is_nan() && value != nodata {
262                                    sum += value;
263                                    n += 1;
264                                }
265                            } else if !value.is_nan() {
266                                sum += value;
267                                n += 1;
268                            }
269                        }
270                    }
271
272                    if n > 0 {
273                        composite[[i, j, k]] = sum / n as f64;
274                        count[[i, j, k]] = n;
275                    }
276                }
277            }
278        }
279
280        info!("Created mean composite");
281        Ok(CompositeResult::new(composite, count))
282    }
283
284    /// Maximum value composite
285    fn max_composite(ts: &TimeSeriesRaster, config: &CompositingConfig) -> Result<CompositeResult> {
286        if ts.is_empty() {
287            return Err(TemporalError::insufficient_data("Empty time series"));
288        }
289
290        let (height, width, n_bands) = ts
291            .expected_shape()
292            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
293
294        let mut composite = Array3::from_elem((height, width, n_bands), f64::NEG_INFINITY);
295        let mut count = Array3::zeros((height, width, n_bands));
296
297        for i in 0..height {
298            for j in 0..width {
299                for k in 0..n_bands {
300                    for entry in ts.entries().values() {
301                        if !entry_passes_cloud_filter(entry, config) {
302                            continue;
303                        }
304                        if let Some(data) = &entry.data {
305                            let value = data[[i, j, k]];
306                            if value_is_valid(value, config) {
307                                if value > composite[[i, j, k]] {
308                                    composite[[i, j, k]] = value;
309                                }
310                                count[[i, j, k]] += 1;
311                            }
312                        }
313                    }
314                }
315            }
316        }
317
318        // Any pixel with no valid observations still holds NEG_INFINITY; map it
319        // back to the configured nodata (or NaN) so callers never see a raw
320        // -inf sentinel.
321        replace_empty_pixels(&mut composite, &count, config);
322
323        info!("Created maximum value composite");
324        Ok(CompositeResult::new(composite, count))
325    }
326
327    /// Minimum value composite
328    fn min_composite(ts: &TimeSeriesRaster, config: &CompositingConfig) -> Result<CompositeResult> {
329        if ts.is_empty() {
330            return Err(TemporalError::insufficient_data("Empty time series"));
331        }
332
333        let (height, width, n_bands) = ts
334            .expected_shape()
335            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
336
337        let mut composite = Array3::from_elem((height, width, n_bands), f64::INFINITY);
338        let mut count = Array3::zeros((height, width, n_bands));
339
340        for i in 0..height {
341            for j in 0..width {
342                for k in 0..n_bands {
343                    for entry in ts.entries().values() {
344                        if !entry_passes_cloud_filter(entry, config) {
345                            continue;
346                        }
347                        if let Some(data) = &entry.data {
348                            let value = data[[i, j, k]];
349                            if value_is_valid(value, config) {
350                                if value < composite[[i, j, k]] {
351                                    composite[[i, j, k]] = value;
352                                }
353                                count[[i, j, k]] += 1;
354                            }
355                        }
356                    }
357                }
358            }
359        }
360
361        // Any pixel with no valid observations still holds INFINITY; map it back
362        // to the configured nodata (or NaN) so callers never see a raw +inf
363        // sentinel.
364        replace_empty_pixels(&mut composite, &count, config);
365
366        info!("Created minimum value composite");
367        Ok(CompositeResult::new(composite, count))
368    }
369
370    /// Maximum NDVI composite
371    fn max_ndvi_composite(
372        ts: &TimeSeriesRaster,
373        config: &CompositingConfig,
374    ) -> Result<CompositeResult> {
375        let red_band = config.red_band.ok_or_else(|| {
376            TemporalError::invalid_parameter("red_band", "required for MaxNDVI composite")
377        })?;
378
379        let nir_band = config.nir_band.ok_or_else(|| {
380            TemporalError::invalid_parameter("nir_band", "required for MaxNDVI composite")
381        })?;
382
383        if ts.is_empty() {
384            return Err(TemporalError::insufficient_data("Empty time series"));
385        }
386
387        let (height, width, n_bands) = ts
388            .expected_shape()
389            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
390
391        if red_band >= n_bands || nir_band >= n_bands {
392            return Err(TemporalError::invalid_parameter(
393                "band_indices",
394                "band indices out of range",
395            ));
396        }
397
398        let mut composite = Array3::zeros((height, width, n_bands));
399        let mut count = Array3::zeros((height, width, n_bands));
400        let mut max_ndvi = Array3::from_elem((height, width, 1), f64::NEG_INFINITY);
401
402        for entry in ts.entries().values() {
403            if let Some(data) = &entry.data {
404                for i in 0..height {
405                    for j in 0..width {
406                        let red = data[[i, j, red_band]];
407                        let nir = data[[i, j, nir_band]];
408
409                        if !red.is_nan() && !nir.is_nan() && (red + nir) != 0.0 {
410                            let ndvi = (nir - red) / (nir + red);
411
412                            if ndvi > max_ndvi[[i, j, 0]] {
413                                max_ndvi[[i, j, 0]] = ndvi;
414                                // Copy all bands from this observation
415                                for k in 0..n_bands {
416                                    composite[[i, j, k]] = data[[i, j, k]];
417                                }
418                                count[[i, j, 0]] += 1;
419                            }
420                        }
421                    }
422                }
423            }
424        }
425
426        info!("Created maximum NDVI composite");
427        Ok(CompositeResult::new(composite, count))
428    }
429
430    /// Quality-weighted composite
431    fn quality_weighted_composite(
432        ts: &TimeSeriesRaster,
433        config: &CompositingConfig,
434    ) -> Result<CompositeResult> {
435        if ts.is_empty() {
436            return Err(TemporalError::insufficient_data("Empty time series"));
437        }
438
439        let (height, width, n_bands) = ts
440            .expected_shape()
441            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
442
443        let mut composite: Array3<f64> = Array3::zeros((height, width, n_bands));
444        let mut count: Array3<usize> = Array3::zeros((height, width, n_bands));
445        let mut weight_sum: Array3<f64> = Array3::zeros((height, width, n_bands));
446
447        for entry in ts.entries().values() {
448            if !entry_passes_cloud_filter(entry, config) {
449                continue;
450            }
451
452            let weight = entry.metadata.quality_score.unwrap_or(1.0) as f64;
453
454            if let Some(data) = &entry.data {
455                for i in 0..height {
456                    for j in 0..width {
457                        for k in 0..n_bands {
458                            let value = data[[i, j, k]];
459                            if value_is_valid(value, config) {
460                                composite[[i, j, k]] += value * weight;
461                                weight_sum[[i, j, k]] += weight;
462                                count[[i, j, k]] += 1;
463                            }
464                        }
465                    }
466                }
467            }
468        }
469
470        // Normalize by weights
471        for i in 0..height {
472            for j in 0..width {
473                for k in 0..n_bands {
474                    if weight_sum[[i, j, k]] > 0.0 {
475                        composite[[i, j, k]] /= weight_sum[[i, j, k]];
476                    }
477                }
478            }
479        }
480
481        // Pixels with no valid observations still hold the 0.0 accumulator seed,
482        // which is indistinguishable from a genuine zero measurement; map them
483        // to the configured nodata (or NaN) instead.
484        replace_empty_pixels(&mut composite, &count, config);
485
486        info!("Created quality-weighted composite");
487        Ok(CompositeResult::new(composite, count))
488    }
489
490    /// First valid value composite
491    fn first_valid_composite(
492        ts: &TimeSeriesRaster,
493        _config: &CompositingConfig,
494    ) -> Result<CompositeResult> {
495        if ts.is_empty() {
496            return Err(TemporalError::insufficient_data("Empty time series"));
497        }
498
499        let (height, width, n_bands) = ts
500            .expected_shape()
501            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
502
503        let mut composite = Array3::zeros((height, width, n_bands));
504        let mut count = Array3::zeros((height, width, n_bands));
505        let mut filled = Array3::from_elem((height, width, n_bands), false);
506
507        for entry in ts.entries().values() {
508            if let Some(data) = &entry.data {
509                for i in 0..height {
510                    for j in 0..width {
511                        for k in 0..n_bands {
512                            if !filled[[i, j, k]] {
513                                let value = data[[i, j, k]];
514                                if !value.is_nan() {
515                                    composite[[i, j, k]] = value;
516                                    count[[i, j, k]] = 1;
517                                    filled[[i, j, k]] = true;
518                                }
519                            }
520                        }
521                    }
522                }
523            }
524        }
525
526        info!("Created first valid value composite");
527        Ok(CompositeResult::new(composite, count))
528    }
529
530    /// Last valid value composite
531    fn last_valid_composite(
532        ts: &TimeSeriesRaster,
533        _config: &CompositingConfig,
534    ) -> Result<CompositeResult> {
535        if ts.is_empty() {
536            return Err(TemporalError::insufficient_data("Empty time series"));
537        }
538
539        let (height, width, n_bands) = ts
540            .expected_shape()
541            .ok_or_else(|| TemporalError::insufficient_data("No shape information"))?;
542
543        let mut composite = Array3::zeros((height, width, n_bands));
544        let mut count = Array3::zeros((height, width, n_bands));
545
546        for entry in ts.entries().values() {
547            if let Some(data) = &entry.data {
548                for i in 0..height {
549                    for j in 0..width {
550                        for k in 0..n_bands {
551                            let value = data[[i, j, k]];
552                            if !value.is_nan() {
553                                composite[[i, j, k]] = value;
554                                count[[i, j, k]] = 1;
555                            }
556                        }
557                    }
558                }
559            }
560        }
561
562        info!("Created last valid value composite");
563        Ok(CompositeResult::new(composite, count))
564    }
565}
566
567#[cfg(test)]
568#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
569mod tests {
570    use super::*;
571    use crate::timeseries::TemporalMetadata;
572    use chrono::{DateTime, NaiveDate, Utc};
573    use scirs2_core::ndarray::Array3;
574
575    fn ts_at(day: u32) -> DateTime<Utc> {
576        let date = NaiveDate::from_ymd_opt(2024, 1, day).expect("valid date");
577        let ndt = date.and_hms_opt(0, 0, 0).expect("valid time");
578        DateTime::from_naive_utc_and_offset(ndt, Utc)
579    }
580
581    fn meta(day: u32, cloud: Option<f32>, quality: Option<f32>) -> TemporalMetadata {
582        let date = NaiveDate::from_ymd_opt(2024, 1, day).expect("valid date");
583        let mut m = TemporalMetadata::new(ts_at(day), date);
584        if let Some(c) = cloud {
585            m = m.with_cloud_cover(c);
586        }
587        if let Some(q) = quality {
588            m = m.with_quality_score(q);
589        }
590        m
591    }
592
593    /// 1x1x1 raster holding a single value.
594    fn scalar_raster(v: f64) -> Array3<f64> {
595        Array3::from_elem((1, 1, 1), v)
596    }
597
598    #[test]
599    fn test_max_composite_respects_cloud_filter() {
600        let mut ts = TimeSeriesRaster::new();
601        // Cloudy scene has the highest value; it must be filtered out.
602        ts.add_raster(meta(1, Some(90.0), None), scalar_raster(100.0))
603            .unwrap();
604        ts.add_raster(meta(2, Some(5.0), None), scalar_raster(42.0))
605            .unwrap();
606
607        let config = CompositingConfig {
608            method: CompositingMethod::Maximum,
609            max_cloud_cover: Some(20.0),
610            nodata: None,
611            ..CompositingConfig::default()
612        };
613
614        let result = TemporalCompositor::composite(&ts, &config).unwrap();
615        // Only the clear scene (42.0) should have been considered.
616        assert_eq!(result.data[[0, 0, 0]], 42.0);
617        assert_eq!(result.count[[0, 0, 0]], 1);
618    }
619
620    #[test]
621    fn test_min_composite_respects_cloud_filter() {
622        let mut ts = TimeSeriesRaster::new();
623        ts.add_raster(meta(1, Some(90.0), None), scalar_raster(1.0))
624            .unwrap();
625        ts.add_raster(meta(2, Some(5.0), None), scalar_raster(42.0))
626            .unwrap();
627
628        let config = CompositingConfig {
629            method: CompositingMethod::Minimum,
630            max_cloud_cover: Some(20.0),
631            nodata: None,
632            ..CompositingConfig::default()
633        };
634
635        let result = TemporalCompositor::composite(&ts, &config).unwrap();
636        assert_eq!(result.data[[0, 0, 0]], 42.0);
637        assert_eq!(result.count[[0, 0, 0]], 1);
638    }
639
640    #[test]
641    fn test_max_composite_all_invalid_yields_nodata_not_infinity() {
642        let mut ts = TimeSeriesRaster::new();
643        ts.add_raster(meta(1, None, None), scalar_raster(f64::NAN))
644            .unwrap();
645        ts.add_raster(meta(2, None, None), scalar_raster(f64::NAN))
646            .unwrap();
647
648        let config = CompositingConfig {
649            method: CompositingMethod::Maximum,
650            max_cloud_cover: None,
651            nodata: Some(-9999.0),
652            ..CompositingConfig::default()
653        };
654
655        let result = TemporalCompositor::composite(&ts, &config).unwrap();
656        let v = result.data[[0, 0, 0]];
657        assert!(
658            v.is_finite() && (v - (-9999.0)).abs() < 1e-9,
659            "all-invalid pixel must become nodata, got {v}"
660        );
661        assert_eq!(result.count[[0, 0, 0]], 0);
662    }
663
664    #[test]
665    fn test_min_composite_all_invalid_yields_nan_when_no_nodata() {
666        let mut ts = TimeSeriesRaster::new();
667        ts.add_raster(meta(1, None, None), scalar_raster(f64::NAN))
668            .unwrap();
669
670        let config = CompositingConfig {
671            method: CompositingMethod::Minimum,
672            max_cloud_cover: None,
673            nodata: None,
674            ..CompositingConfig::default()
675        };
676
677        let result = TemporalCompositor::composite(&ts, &config).unwrap();
678        let v = result.data[[0, 0, 0]];
679        assert!(
680            v.is_nan(),
681            "all-invalid pixel with no nodata must become NaN, not +inf, got {v}"
682        );
683    }
684
685    #[test]
686    fn test_max_composite_honors_nodata_sentinel() {
687        let mut ts = TimeSeriesRaster::new();
688        // The nodata sentinel (999.0) is the numerically largest value but must
689        // be ignored so the real maximum (10.0) wins.
690        ts.add_raster(meta(1, None, None), scalar_raster(999.0))
691            .unwrap();
692        ts.add_raster(meta(2, None, None), scalar_raster(10.0))
693            .unwrap();
694
695        let config = CompositingConfig {
696            method: CompositingMethod::Maximum,
697            max_cloud_cover: None,
698            nodata: Some(999.0),
699            ..CompositingConfig::default()
700        };
701
702        let result = TemporalCompositor::composite(&ts, &config).unwrap();
703        assert_eq!(result.data[[0, 0, 0]], 10.0);
704        assert_eq!(result.count[[0, 0, 0]], 1);
705    }
706
707    #[test]
708    fn test_quality_weighted_respects_cloud_filter_and_nodata() {
709        let mut ts = TimeSeriesRaster::new();
710        ts.add_raster(meta(1, Some(95.0), Some(1.0)), scalar_raster(100.0))
711            .unwrap();
712        ts.add_raster(meta(2, Some(2.0), Some(1.0)), scalar_raster(20.0))
713            .unwrap();
714
715        let config = CompositingConfig {
716            method: CompositingMethod::QualityWeighted,
717            max_cloud_cover: Some(50.0),
718            nodata: None,
719            ..CompositingConfig::default()
720        };
721
722        let result = TemporalCompositor::composite(&ts, &config).unwrap();
723        // Cloudy scene filtered; only 20.0 with weight 1.0 remains.
724        assert!((result.data[[0, 0, 0]] - 20.0).abs() < 1e-9);
725        assert_eq!(result.count[[0, 0, 0]], 1);
726    }
727}