1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22pub enum CompositingMethod {
23 Median,
25 Mean,
27 Maximum,
29 Minimum,
31 MaxNDVI,
33 QualityWeighted,
35 FirstValid,
37 LastValid,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct CompositingConfig {
44 pub method: CompositingMethod,
46 pub max_cloud_cover: Option<f32>,
48 pub min_quality: Option<f32>,
50 pub nodata: Option<f64>,
52 pub red_band: Option<usize>,
54 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#[derive(Debug, Clone)]
73pub struct CompositeResult {
74 pub data: Array3<f64>,
76 pub count: Array3<usize>,
78 pub quality: Option<Array3<f64>>,
80}
81
82impl CompositeResult {
83 #[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 #[must_use]
95 pub fn with_quality(mut self, quality: Array3<f64>) -> Self {
96 self.quality = Some(quality);
97 self
98 }
99}
100
101fn 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
112fn 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
124fn 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
146pub struct TemporalCompositor;
148
149impl TemporalCompositor {
150 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 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 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 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 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 replace_empty_pixels(&mut composite, &count, config);
322
323 info!("Created maximum value composite");
324 Ok(CompositeResult::new(composite, count))
325 }
326
327 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 replace_empty_pixels(&mut composite, &count, config);
365
366 info!("Created minimum value composite");
367 Ok(CompositeResult::new(composite, count))
368 }
369
370 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 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 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 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 replace_empty_pixels(&mut composite, &count, config);
485
486 info!("Created quality-weighted composite");
487 Ok(CompositeResult::new(composite, count))
488 }
489
490 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 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 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 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 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 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 assert!((result.data[[0, 0, 0]] - 20.0).abs() < 1e-9);
725 assert_eq!(result.count[[0, 0, 0]], 1);
726 }
727}