RadGEEToolbox 1.7.2__py3-none-any.whl → 1.7.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -159,6 +159,11 @@ class LandsatCollection:
159
159
  self._monthly_max = None
160
160
  self._monthly_min = None
161
161
  self._monthly_sum = None
162
+ self._yearly_median = None
163
+ self._yearly_mean = None
164
+ self._yearly_max = None
165
+ self._yearly_min = None
166
+ self._yearly_sum = None
162
167
  self._mean = None
163
168
  self._max = None
164
169
  self._min = None
@@ -239,17 +244,17 @@ class LandsatCollection:
239
244
  ndwi_calc.updateMask(ndwi_calc.gte(threshold))
240
245
  .rename("ndwi")
241
246
  .copyProperties(image)
242
- .set("threshold", threshold),
247
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
243
248
  ndwi_calc.updateMask(ndwi_calc.gte(ng_threshold))
244
249
  .rename("ndwi")
245
250
  .copyProperties(image)
246
- .set("threshold", ng_threshold),
251
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
247
252
  )
248
253
  else:
249
254
  water = (
250
255
  ndwi_calc.updateMask(ndwi_calc.gte(threshold))
251
256
  .rename("ndwi")
252
- .copyProperties(image)
257
+ .copyProperties(image).set('system:time_start', image.get('system:time_start'))
253
258
  )
254
259
  return water
255
260
 
@@ -282,17 +287,17 @@ class LandsatCollection:
282
287
  mndwi_calc.updateMask(mndwi_calc.gte(threshold))
283
288
  .rename("mndwi")
284
289
  .copyProperties(image)
285
- .set("threshold", threshold),
290
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
286
291
  mndwi_calc.updateMask(mndwi_calc.gte(ng_threshold))
287
292
  .rename("mndwi")
288
293
  .copyProperties(image)
289
- .set("threshold", ng_threshold),
294
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
290
295
  )
291
296
  else:
292
297
  water = (
293
298
  mndwi_calc.updateMask(mndwi_calc.gte(threshold))
294
299
  .rename("mndwi")
295
- .copyProperties(image)
300
+ .copyProperties(image).set('system:time_start', image.get('system:time_start'))
296
301
  )
297
302
  return water
298
303
 
@@ -320,17 +325,17 @@ class LandsatCollection:
320
325
  ndvi_calc.updateMask(ndvi_calc.gte(threshold))
321
326
  .rename("ndvi")
322
327
  .copyProperties(image)
323
- .set("threshold", threshold),
328
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
324
329
  ndvi_calc.updateMask(ndvi_calc.gte(ng_threshold))
325
330
  .rename("ndvi")
326
331
  .copyProperties(image)
327
- .set("threshold", ng_threshold),
332
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
328
333
  )
329
334
  else:
330
335
  vegetation = (
331
336
  ndvi_calc.updateMask(ndvi_calc.gte(threshold))
332
337
  .rename("ndvi")
333
- .copyProperties(image)
338
+ .copyProperties(image).set('system:time_start', image.get('system:time_start'))
334
339
  )
335
340
  return vegetation
336
341
 
@@ -356,17 +361,17 @@ class LandsatCollection:
356
361
  halite_index.updateMask(halite_index.gte(threshold))
357
362
  .rename("halite")
358
363
  .copyProperties(image)
359
- .set("threshold", threshold),
364
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
360
365
  halite_index.updateMask(halite_index.gte(ng_threshold))
361
366
  .rename("halite")
362
367
  .copyProperties(image)
363
- .set("threshold", ng_threshold),
368
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
364
369
  )
365
370
  else:
366
371
  halite = (
367
372
  halite_index.updateMask(halite_index.gte(threshold))
368
373
  .rename("halite")
369
- .copyProperties(image)
374
+ .copyProperties(image).set('system:time_start', image.get('system:time_start'))
370
375
  )
371
376
  return halite
372
377
 
@@ -392,17 +397,17 @@ class LandsatCollection:
392
397
  gypsum_index.updateMask(gypsum_index.gte(threshold))
393
398
  .rename("gypsum")
394
399
  .copyProperties(image)
395
- .set("threshold", threshold),
400
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
396
401
  gypsum_index.updateMask(gypsum_index.gte(ng_threshold))
397
402
  .rename("gypsum")
398
403
  .copyProperties(image)
399
- .set("threshold", ng_threshold),
404
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
400
405
  )
401
406
  else:
402
407
  gypsum = (
403
408
  gypsum_index.updateMask(gypsum_index.gte(threshold))
404
409
  .rename("gypsum")
405
- .copyProperties(image)
410
+ .copyProperties(image).set('system:time_start', image.get('system:time_start'))
406
411
  )
407
412
  return gypsum
408
413
 
@@ -427,11 +432,11 @@ class LandsatCollection:
427
432
  NDTI.updateMask(NDTI.gte(threshold))
428
433
  .rename("ndti")
429
434
  .copyProperties(image)
430
- .set("threshold", threshold),
435
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
431
436
  NDTI.updateMask(NDTI.gte(ng_threshold))
432
437
  .rename("ndti")
433
438
  .copyProperties(image)
434
- .set("threshold", ng_threshold),
439
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
435
440
  )
436
441
  else:
437
442
  turbidity = (
@@ -471,17 +476,17 @@ class LandsatCollection:
471
476
  KIVU.updateMask(KIVU.gte(threshold))
472
477
  .rename("kivu")
473
478
  .copyProperties(image)
474
- .set("threshold", threshold),
479
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
475
480
  KIVU.updateMask(KIVU.gte(ng_threshold))
476
481
  .rename("kivu")
477
482
  .copyProperties(image)
478
- .set("threshold", ng_threshold),
483
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
479
484
  )
480
485
  else:
481
486
  chlorophyll = (
482
487
  KIVU.updateMask(KIVU.gte(threshold))
483
488
  .rename("kivu")
484
- .copyProperties(image)
489
+ .copyProperties(image).set('system:time_start', image.get('system:time_start'))
485
490
  )
486
491
  return chlorophyll
487
492
 
@@ -505,8 +510,8 @@ class LandsatCollection:
505
510
  # otherwise treat as OLI and copy properties after renaming band to "albedo"
506
511
  albedo = ee.Algorithms.If(
507
512
  ee.String(image.get("SPACECRAFT_ID")).equals("LANDSAT_5"),
508
- image.expression(TM_expression).rename("albedo").copyProperties(image),
509
- image.expression(OLI_expression).rename("albedo").copyProperties(image))
513
+ image.expression(TM_expression).rename("albedo").copyProperties(image).set('system:time_start', image.get('system:time_start')),
514
+ image.expression(OLI_expression).rename("albedo").copyProperties(image).set('system:time_start', image.get('system:time_start')))
510
515
  return albedo
511
516
 
512
517
  @staticmethod
@@ -530,14 +535,14 @@ class LandsatCollection:
530
535
  ndsi_calc.updateMask(ndsi_calc.gte(threshold))
531
536
  .rename("ndsi")
532
537
  .copyProperties(image)
533
- .set("threshold", threshold),
538
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
534
539
  ndsi_calc.updateMask(ndsi_calc.gte(ng_threshold))
535
540
  .rename("ndsi")
536
541
  .copyProperties(image)
537
- .set("threshold", ng_threshold),
542
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
538
543
  )
539
544
  else:
540
- ndsi = ndsi_calc.updateMask(ndsi_calc.gte(threshold)).rename("ndsi").copyProperties(image).set("threshold", threshold)
545
+ ndsi = ndsi_calc.updateMask(ndsi_calc.gte(threshold)).rename("ndsi").copyProperties(image).set("threshold", threshold, 'system:time_start', image.get('system:time_start'))
541
546
  return ndsi
542
547
 
543
548
  @staticmethod
@@ -567,14 +572,14 @@ class LandsatCollection:
567
572
  evi_calc.updateMask(evi_calc.gte(threshold))
568
573
  .rename("evi")
569
574
  .copyProperties(image)
570
- .set("threshold", threshold),
575
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
571
576
  evi_calc.updateMask(evi_calc.gte(ng_threshold))
572
577
  .rename("evi")
573
578
  .copyProperties(image)
574
- .set("threshold", ng_threshold),
579
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
575
580
  )
576
581
  else:
577
- evi = evi_calc.updateMask(evi_calc.gte(threshold)).rename("evi").copyProperties(image).set("threshold", threshold)
582
+ evi = evi_calc.updateMask(evi_calc.gte(threshold)).rename("evi").copyProperties(image).set("threshold", threshold, 'system:time_start', image.get('system:time_start'))
578
583
  return evi
579
584
 
580
585
  @staticmethod
@@ -600,14 +605,14 @@ class LandsatCollection:
600
605
  savi_calc.updateMask(savi_calc.gte(threshold))
601
606
  .rename("savi")
602
607
  .copyProperties(image)
603
- .set("threshold", threshold),
608
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
604
609
  savi_calc.updateMask(savi_calc.gte(ng_threshold))
605
610
  .rename("savi")
606
611
  .copyProperties(image)
607
- .set("threshold", ng_threshold),
612
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
608
613
  )
609
614
  else:
610
- savi = savi_calc.updateMask(savi_calc.gte(threshold)).rename("savi").copyProperties(image).set("threshold", threshold)
615
+ savi = savi_calc.updateMask(savi_calc.gte(threshold)).rename("savi").copyProperties(image).set("threshold", threshold, 'system:time_start', image.get('system:time_start'))
611
616
  return savi
612
617
 
613
618
  @staticmethod
@@ -633,14 +638,14 @@ class LandsatCollection:
633
638
  msavi_calc.updateMask(msavi_calc.gte(threshold))
634
639
  .rename("msavi")
635
640
  .copyProperties(image)
636
- .set("threshold", threshold),
641
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
637
642
  msavi_calc.updateMask(msavi_calc.gte(ng_threshold))
638
643
  .rename("msavi")
639
644
  .copyProperties(image)
640
- .set("threshold", ng_threshold),
645
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
641
646
  )
642
647
  else:
643
- msavi = msavi_calc.updateMask(msavi_calc.gte(threshold)).rename("msavi").copyProperties(image).set("threshold", threshold)
648
+ msavi = msavi_calc.updateMask(msavi_calc.gte(threshold)).rename("msavi").copyProperties(image).set("threshold", threshold, 'system:time_start', image.get('system:time_start'))
644
649
  return msavi
645
650
 
646
651
  @staticmethod
@@ -666,14 +671,14 @@ class LandsatCollection:
666
671
  ndmi_calc.updateMask(ndmi_calc.gte(threshold))
667
672
  .rename("ndmi")
668
673
  .copyProperties(image)
669
- .set("threshold", threshold),
674
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
670
675
  ndmi_calc.updateMask(ndmi_calc.gte(ng_threshold))
671
676
  .rename("ndmi")
672
677
  .copyProperties(image)
673
- .set("threshold", ng_threshold),
678
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
674
679
  )
675
680
  else:
676
- ndmi = ndmi_calc.updateMask(ndmi_calc.gte(threshold)).rename("ndmi").copyProperties(image).set("threshold", threshold)
681
+ ndmi = ndmi_calc.updateMask(ndmi_calc.gte(threshold)).rename("ndmi").copyProperties(image).set("threshold", threshold, 'system:time_start', image.get('system:time_start'))
677
682
  return ndmi
678
683
 
679
684
  @staticmethod
@@ -698,14 +703,14 @@ class LandsatCollection:
698
703
  nbr_calc.updateMask(nbr_calc.gte(threshold))
699
704
  .rename("nbr")
700
705
  .copyProperties(image)
701
- .set("threshold", threshold),
706
+ .set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
702
707
  nbr_calc.updateMask(nbr_calc.gte(ng_threshold))
703
708
  .rename("nbr")
704
709
  .copyProperties(image)
705
- .set("threshold", ng_threshold),
710
+ .set("threshold", ng_threshold, 'system:time_start', image.get('system:time_start')),
706
711
  )
707
712
  else:
708
- nbr = nbr_calc.updateMask(nbr_calc.gte(threshold)).rename("nbr").copyProperties(image).set("threshold", threshold)
713
+ nbr = nbr_calc.updateMask(nbr_calc.gte(threshold)).rename("nbr").copyProperties(image).set("threshold", threshold, 'system:time_start', image.get('system:time_start'))
709
714
  return nbr
710
715
 
711
716
  @staticmethod
@@ -780,7 +785,7 @@ class LandsatCollection:
780
785
  WaterBitMask = ee.Number(2).pow(7).int()
781
786
  qa = image.select("QA_PIXEL")
782
787
  water_extract = qa.bitwiseAnd(WaterBitMask).eq(0)
783
- masked_image = image.updateMask(water_extract).copyProperties(image)
788
+ masked_image = image.updateMask(water_extract).copyProperties(image).set('system:time_start', image.get('system:time_start'))
784
789
  return masked_image
785
790
 
786
791
  @staticmethod
@@ -805,18 +810,19 @@ class LandsatCollection:
805
810
  ndwi_calc.updateMask(ndwi_calc.gte(threshold))
806
811
  .rename("ndwi")
807
812
  .copyProperties(image)
813
+ .set('system:time_start', image.get('system:time_start'))
808
814
  )
809
815
  if ng_threshold != None:
810
816
  water = ee.Algorithms.If(
811
817
  ee.String(image.get("SPACECRAFT_ID")).equals("LANDSAT_5"),
812
- image.updateMask(ndwi_calc.lt(threshold)).set("threshold", threshold),
813
- image.updateMask(ndwi_calc.lt(ng_threshold)).set(
814
- "threshold", ng_threshold
818
+ image.updateMask(ndwi_calc.lt(threshold)).copyProperties(image).set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
819
+ image.updateMask(ndwi_calc.lt(ng_threshold)).copyProperties(image).set(
820
+ "threshold", ng_threshold, 'system:time_start', image.get('system:time_start')
815
821
  ),
816
822
  )
817
823
  else:
818
- water = image.updateMask(ndwi_calc.lt(threshold)).set(
819
- "threshold", threshold
824
+ water = image.updateMask(ndwi_calc.lt(threshold)).copyProperties(image).set(
825
+ "threshold", threshold, 'system:time_start', image.get('system:time_start')
820
826
  )
821
827
  return water
822
828
 
@@ -834,7 +840,7 @@ class LandsatCollection:
834
840
  WaterBitMask = ee.Number(2).pow(7).int()
835
841
  qa = image.select("QA_PIXEL")
836
842
  water_extract = qa.bitwiseAnd(WaterBitMask).neq(0)
837
- masked_image = image.updateMask(water_extract).copyProperties(image)
843
+ masked_image = image.updateMask(water_extract).copyProperties(image).set('system:time_start', image.get('system:time_start'))
838
844
  return masked_image
839
845
 
840
846
  @staticmethod
@@ -858,18 +864,19 @@ class LandsatCollection:
858
864
  ndwi_calc.updateMask(ndwi_calc.gte(threshold))
859
865
  .rename("ndwi")
860
866
  .copyProperties(image)
867
+ .set('system:time_start', image.get('system:time_start'))
861
868
  )
862
869
  if ng_threshold != None:
863
870
  water = ee.Algorithms.If(
864
871
  ee.String(image.get("SPACECRAFT_ID")).equals("LANDSAT_5"),
865
- image.updateMask(ndwi_calc.gte(threshold)).set("threshold", threshold),
866
- image.updateMask(ndwi_calc.gte(ng_threshold)).set(
867
- "threshold", ng_threshold
872
+ image.updateMask(ndwi_calc.gte(threshold)).copyProperties(image).set("threshold", threshold, 'system:time_start', image.get('system:time_start')),
873
+ image.updateMask(ndwi_calc.gte(ng_threshold)).copyProperties(image).set(
874
+ "threshold", ng_threshold, 'system:time_start', image.get('system:time_start')
868
875
  ),
869
876
  )
870
877
  else:
871
- water = image.updateMask(ndwi_calc.gte(threshold)).set(
872
- "threshold", threshold
878
+ water = image.updateMask(ndwi_calc.gte(threshold)).copyProperties(image).set(
879
+ "threshold", threshold, 'system:time_start', image.get('system:time_start')
873
880
  )
874
881
  return water
875
882
 
@@ -899,7 +906,7 @@ class LandsatCollection:
899
906
  if add_band_to_original_image:
900
907
  return image.addBands(band_to_mask_image.updateMask(mask).rename(band_to_mask), overwrite=True)
901
908
  else:
902
- return ee.Image(band_to_mask_image.updateMask(mask).rename(band_to_mask).copyProperties(image))
909
+ return ee.Image(band_to_mask_image.updateMask(mask).rename(band_to_mask).copyProperties(image).set('system:time_start', image.get('system:time_start')))
903
910
 
904
911
  @staticmethod
905
912
  def mask_via_singleband_image_fn(image_to_mask, image_for_mask, threshold, band_name_to_mask=None, band_name_for_mask=None, mask_above=True):
@@ -935,7 +942,7 @@ class LandsatCollection:
935
942
  mask = band_for_mask_image.gt(threshold)
936
943
  else:
937
944
  mask = band_for_mask_image.lt(threshold)
938
- return band_to_mask_image.updateMask(mask).rename(band_to_mask).copyProperties(image_to_mask)
945
+ return band_to_mask_image.updateMask(mask).rename(band_to_mask).copyProperties(image_to_mask).set('system:time_start', image_to_mask.get('system:time_start'))
939
946
 
940
947
  @staticmethod
941
948
  def halite_mask(image, threshold, ng_threshold=None):
@@ -959,11 +966,11 @@ class LandsatCollection:
959
966
  if ng_threshold != None:
960
967
  mask = ee.Algorithms.If(
961
968
  ee.String(image.get("SPACECRAFT_ID")).equals("LANDSAT_5"),
962
- image.updateMask(halite_index.lt(threshold)).copyProperties(image),
963
- image.updateMask(halite_index.lt(ng_threshold)).copyProperties(image),
969
+ image.updateMask(halite_index.lt(threshold)).copyProperties(image).set('system:time_start', image.get('system:time_start')),
970
+ image.updateMask(halite_index.lt(ng_threshold)).copyProperties(image).set('system:time_start', image.get('system:time_start')),
964
971
  )
965
972
  else:
966
- mask = image.updateMask(halite_index.lt(threshold)).copyProperties(image)
973
+ mask = image.updateMask(halite_index.lt(threshold)).copyProperties(image).set('system:time_start', image.get('system:time_start'))
967
974
  return mask
968
975
 
969
976
  @staticmethod
@@ -1000,11 +1007,13 @@ class LandsatCollection:
1000
1007
  gypsum_index.updateMask(halite_index.lt(halite_threshold))
1001
1008
  .updateMask(gypsum_index.lt(gypsum_threshold))
1002
1009
  .rename("carbonate_muds")
1003
- .copyProperties(image),
1010
+ .copyProperties(image)
1011
+ .set('system:time_start', image.get('system:time_start')),
1004
1012
  gypsum_index.updateMask(halite_index.lt(halite_ng_threshold))
1005
1013
  .updateMask(gypsum_index.lt(gypsum_ng_threshold))
1006
1014
  .rename("carbonate_muds")
1007
- .copyProperties(image),
1015
+ .copyProperties(image)
1016
+ .set('system:time_start', image.get('system:time_start')),
1008
1017
  )
1009
1018
  else:
1010
1019
  mask = (
@@ -1012,6 +1021,7 @@ class LandsatCollection:
1012
1021
  .updateMask(gypsum_index.lt(gypsum_threshold))
1013
1022
  .rename("carbonate_muds")
1014
1023
  .copyProperties(image)
1024
+ .set('system:time_start', image.get('system:time_start'))
1015
1025
  )
1016
1026
  return mask
1017
1027
 
@@ -1781,6 +1791,7 @@ class LandsatCollection:
1781
1791
  """
1782
1792
  if self._monthly_mean is None:
1783
1793
  collection = self.collection
1794
+ # Capture projection from the first image to restore it after reduction
1784
1795
  target_proj = collection.first().projection()
1785
1796
  # Get the start and end dates of the entire collection.
1786
1797
  date_range = collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
@@ -2092,6 +2103,391 @@ class LandsatCollection:
2092
2103
  pass
2093
2104
 
2094
2105
  return self._monthly_min
2106
+
2107
+ def yearly_mean_collection(self, start_month=1, end_month=12):
2108
+ """
2109
+ Creates a yearly mean composite from the collection, with optional monthly filtering.
2110
+
2111
+ This function computes the mean for each year within the collection's date range.
2112
+ You can specify a range of months (e.g., start_month=6, end_month=10 for June-October)
2113
+ to calculate the mean only using imagery from that specific season for each year.
2114
+
2115
+ The resulting images have 'system:time_start', 'year', 'image_count', 'season_start',
2116
+ 'season_end', and 'Date_Filter' properties. Years with no images (after filtering) are excluded.
2117
+
2118
+ Args:
2119
+ start_month (int): The starting month (1-12) for the filter. Defaults to 1 (January).
2120
+ end_month (int): The ending month (1-12) for the filter. Defaults to 12 (December).
2121
+
2122
+ Returns:
2123
+ Object: A new instance of the same class (e.g., LandsatCollection) containing the yearly mean composites.
2124
+ """
2125
+ if self._yearly_mean is None:
2126
+
2127
+ date_range = self.collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
2128
+ start_date_full = ee.Date(date_range.get('min'))
2129
+ end_date_full = ee.Date(date_range.get('max'))
2130
+
2131
+ start_year = start_date_full.get('year')
2132
+ end_year = end_date_full.get('year')
2133
+
2134
+ if start_month != 1 or end_month != 12:
2135
+ processing_collection = self.collection.filter(ee.Filter.calendarRange(start_month, end_month, 'month'))
2136
+ else:
2137
+ processing_collection = self.collection
2138
+
2139
+ # Capture projection from the first image to restore it after reduction
2140
+ target_proj = self.collection.first().projection()
2141
+
2142
+ years = ee.List.sequence(start_year, end_year)
2143
+
2144
+ def create_yearly_composite(year):
2145
+ year = ee.Number(year)
2146
+ # Define the full calendar year range
2147
+ start_of_year = ee.Date.fromYMD(year, 1, 1)
2148
+ end_of_year = start_of_year.advance(1, 'year')
2149
+
2150
+ yearly_subset = processing_collection.filterDate(start_of_year, end_of_year)
2151
+
2152
+ # Calculate stats
2153
+ image_count = yearly_subset.size()
2154
+ yearly_reduction = yearly_subset.mean()
2155
+
2156
+ # Define the timestamp for the composite.
2157
+ # We use the start_month of that year to accurately reflect the data start time.
2158
+ composite_date = ee.Date.fromYMD(year, start_month, 1)
2159
+
2160
+ return yearly_reduction.set({
2161
+ 'system:time_start': composite_date.millis(),
2162
+ 'year': year,
2163
+ 'month': start_month,
2164
+ 'Date_Filter': composite_date.format('YYYY-MM-dd'),
2165
+ 'image_count': image_count,
2166
+ 'season_start': start_month,
2167
+ 'season_end': end_month
2168
+ }).reproject(target_proj)
2169
+
2170
+ # Map the function over the years list
2171
+ yearly_composites_list = years.map(create_yearly_composite)
2172
+
2173
+ # Convert to Collection
2174
+ yearly_collection = ee.ImageCollection.fromImages(yearly_composites_list)
2175
+
2176
+ # Filter out any composites that were created from zero images.
2177
+ final_collection = yearly_collection.filter(ee.Filter.gt('image_count', 0))
2178
+
2179
+ self._yearly_mean = LandsatCollection(collection=final_collection)
2180
+ else:
2181
+ pass
2182
+ return self._yearly_mean
2183
+
2184
+ def yearly_median_collection(self, start_month=1, end_month=12):
2185
+ """
2186
+ Creates a yearly median composite from the collection, with optional monthly filtering.
2187
+
2188
+ This function computes the median for each year within the collection's date range.
2189
+ You can specify a range of months (e.g., start_month=6, end_month=10 for June-October)
2190
+ to calculate the median only using imagery from that specific season for each year.
2191
+ The resulting images have 'system:time_start', 'year', 'image_count', 'season_start',
2192
+ 'season_end', and 'Date_Filter' properties. Years with no images (after filtering) are excluded.
2193
+
2194
+ Args:
2195
+ start_month (int): The starting month (1-12) for the filter. Defaults to 1 (January).
2196
+ end_month (int): The ending month (1-12) for the filter. Defaults to 12 (December).
2197
+
2198
+ Returns:
2199
+ Object: A new instance of the same class (e.g., LandsatCollection) containing the yearly median composites.
2200
+ """
2201
+ if self._yearly_median is None:
2202
+
2203
+ date_range = self.collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
2204
+ start_date_full = ee.Date(date_range.get('min'))
2205
+ end_date_full = ee.Date(date_range.get('max'))
2206
+
2207
+ start_year = start_date_full.get('year')
2208
+ end_year = end_date_full.get('year')
2209
+
2210
+ if start_month != 1 or end_month != 12:
2211
+ processing_collection = self.collection.filter(ee.Filter.calendarRange(start_month, end_month, 'month'))
2212
+ else:
2213
+ processing_collection = self.collection
2214
+
2215
+ # Capture projection from the first image to restore it after reduction
2216
+ target_proj = self.collection.first().projection()
2217
+
2218
+ years = ee.List.sequence(start_year, end_year)
2219
+
2220
+ def create_yearly_composite(year):
2221
+ year = ee.Number(year)
2222
+ # Define the full calendar year range
2223
+ start_of_year = ee.Date.fromYMD(year, 1, 1)
2224
+ end_of_year = start_of_year.advance(1, 'year')
2225
+
2226
+ # Filter to the specific year using the PRE-FILTERED seasonal collection
2227
+ yearly_subset = processing_collection.filterDate(start_of_year, end_of_year)
2228
+
2229
+ # Calculate stats
2230
+ image_count = yearly_subset.size()
2231
+ yearly_reduction = yearly_subset.median()
2232
+
2233
+ # Define the timestamp for the composite.
2234
+ # We use the start_month of that year to accurately reflect the data start time.
2235
+ composite_date = ee.Date.fromYMD(year, start_month, 1)
2236
+
2237
+ return yearly_reduction.set({
2238
+ 'system:time_start': composite_date.millis(),
2239
+ 'year': year,
2240
+ 'month': start_month,
2241
+ 'Date_Filter': composite_date.format('YYYY-MM-dd'),
2242
+ 'image_count': image_count,
2243
+ 'season_start': start_month,
2244
+ 'season_end': end_month
2245
+ }).reproject(target_proj)
2246
+
2247
+ # Map the function over the years list
2248
+ yearly_composites_list = years.map(create_yearly_composite)
2249
+
2250
+ # Convert to Collection
2251
+ yearly_collection = ee.ImageCollection.fromImages(yearly_composites_list)
2252
+
2253
+ # Filter out any composites that were created from zero images.
2254
+ final_collection = yearly_collection.filter(ee.Filter.gt('image_count', 0))
2255
+
2256
+ self._yearly_median = LandsatCollection(collection=final_collection)
2257
+ else:
2258
+ pass
2259
+ return self._yearly_median
2260
+
2261
+ def yearly_max_collection(self, start_month=1, end_month=12):
2262
+ """
2263
+ Creates a yearly max composite from the collection, with optional monthly filtering.
2264
+
2265
+ This function computes the max for each year within the collection's date range.
2266
+ You can specify a range of months (e.g., start_month=6, end_month=10 for June-October)
2267
+ to calculate the max only using imagery from that specific season for each year.
2268
+ The resulting images have 'system:time_start', 'year', 'image_count', 'season_start',
2269
+ 'season_end', and 'Date_Filter' properties. Years with no images (after filtering) are excluded.
2270
+
2271
+ Args:
2272
+ start_month (int): The starting month (1-12) for the filter. Defaults to 1 (January).
2273
+ end_month (int): The ending month (1-12) for the filter. Defaults to 12 (December).
2274
+
2275
+ Returns:
2276
+ Object: A new instance of the same class (e.g., LandsatCollection) containing the yearly max composites.
2277
+ """
2278
+ if self._yearly_max is None:
2279
+
2280
+ date_range = self.collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
2281
+ start_date_full = ee.Date(date_range.get('min'))
2282
+ end_date_full = ee.Date(date_range.get('max'))
2283
+
2284
+ start_year = start_date_full.get('year')
2285
+ end_year = end_date_full.get('year')
2286
+
2287
+ if start_month != 1 or end_month != 12:
2288
+ processing_collection = self.collection.filter(ee.Filter.calendarRange(start_month, end_month, 'month'))
2289
+ else:
2290
+ processing_collection = self.collection
2291
+
2292
+ # Capture projection from the first image to restore it after reduction
2293
+ target_proj = self.collection.first().projection()
2294
+
2295
+ years = ee.List.sequence(start_year, end_year)
2296
+
2297
+ def create_yearly_composite(year):
2298
+ year = ee.Number(year)
2299
+ # Define the full calendar year range
2300
+ start_of_year = ee.Date.fromYMD(year, 1, 1)
2301
+ end_of_year = start_of_year.advance(1, 'year')
2302
+
2303
+ # Filter to the specific year using the PRE-FILTERED seasonal collection
2304
+ yearly_subset = processing_collection.filterDate(start_of_year, end_of_year)
2305
+
2306
+ # Calculate stats
2307
+ image_count = yearly_subset.size()
2308
+ yearly_reduction = yearly_subset.max()
2309
+
2310
+ # Define the timestamp for the composite.
2311
+ # We use the start_month of that year to accurately reflect the data start time.
2312
+ composite_date = ee.Date.fromYMD(year, start_month, 1)
2313
+
2314
+ return yearly_reduction.set({
2315
+ 'system:time_start': composite_date.millis(),
2316
+ 'year': year,
2317
+ 'month': start_month,
2318
+ 'Date_Filter': composite_date.format('YYYY-MM-dd'),
2319
+ 'image_count': image_count,
2320
+ 'season_start': start_month,
2321
+ 'season_end': end_month
2322
+ }).reproject(target_proj)
2323
+
2324
+ # Map the function over the years list
2325
+ yearly_composites_list = years.map(create_yearly_composite)
2326
+
2327
+ # Convert to Collection
2328
+ yearly_collection = ee.ImageCollection.fromImages(yearly_composites_list)
2329
+
2330
+ # Filter out any composites that were created from zero images.
2331
+ final_collection = yearly_collection.filter(ee.Filter.gt('image_count', 0))
2332
+
2333
+ self._yearly_max = LandsatCollection(collection=final_collection)
2334
+ else:
2335
+ pass
2336
+ return self._yearly_max
2337
+
2338
+ def yearly_min_collection(self, start_month=1, end_month=12):
2339
+ """
2340
+ Creates a yearly min composite from the collection, with optional monthly filtering.
2341
+
2342
+ This function computes the min for each year within the collection's date range.
2343
+ You can specify a range of months (e.g., start_month=6, end_month=10 for June-October)
2344
+ to calculate the min only using imagery from that specific season for each year.
2345
+ The resulting images have 'system:time_start', 'year', 'image_count', 'season_start',
2346
+ 'season_end', and 'Date_Filter' properties. Years with no images (after filtering) are excluded.
2347
+
2348
+ Args:
2349
+ start_month (int): The starting month (1-12) for the filter. Defaults to 1 (January).
2350
+ end_month (int): The ending month (1-12) for the filter. Defaults to 12 (December).
2351
+
2352
+ Returns:
2353
+ Object: A new instance of the same class (e.g., LandsatCollection) containing the yearly min composites.
2354
+ """
2355
+ if self._yearly_min is None:
2356
+
2357
+ date_range = self.collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
2358
+ start_date_full = ee.Date(date_range.get('min'))
2359
+ end_date_full = ee.Date(date_range.get('max'))
2360
+
2361
+ start_year = start_date_full.get('year')
2362
+ end_year = end_date_full.get('year')
2363
+
2364
+ if start_month != 1 or end_month != 12:
2365
+ processing_collection = self.collection.filter(ee.Filter.calendarRange(start_month, end_month, 'month'))
2366
+ else:
2367
+ processing_collection = self.collection
2368
+
2369
+ # Capture projection from the first image to restore it after reduction
2370
+ target_proj = self.collection.first().projection()
2371
+
2372
+ years = ee.List.sequence(start_year, end_year)
2373
+
2374
+ def create_yearly_composite(year):
2375
+ year = ee.Number(year)
2376
+ # Define the full calendar year range
2377
+ start_of_year = ee.Date.fromYMD(year, 1, 1)
2378
+ end_of_year = start_of_year.advance(1, 'year')
2379
+
2380
+ # Filter to the specific year using the PRE-FILTERED seasonal collection
2381
+ yearly_subset = processing_collection.filterDate(start_of_year, end_of_year)
2382
+
2383
+ # Calculate stats
2384
+ image_count = yearly_subset.size()
2385
+ yearly_reduction = yearly_subset.min()
2386
+
2387
+ # Define the timestamp for the composite.
2388
+ # We use the start_month of that year to accurately reflect the data start time.
2389
+ composite_date = ee.Date.fromYMD(year, start_month, 1)
2390
+
2391
+ return yearly_reduction.set({
2392
+ 'system:time_start': composite_date.millis(),
2393
+ 'year': year,
2394
+ 'month': start_month,
2395
+ 'Date_Filter': composite_date.format('YYYY-MM-dd'),
2396
+ 'image_count': image_count,
2397
+ 'season_start': start_month,
2398
+ 'season_end': end_month
2399
+ }).reproject(target_proj)
2400
+
2401
+ # Map the function over the years list
2402
+ yearly_composites_list = years.map(create_yearly_composite)
2403
+
2404
+ # Convert to Collection
2405
+ yearly_collection = ee.ImageCollection.fromImages(yearly_composites_list)
2406
+
2407
+ # Filter out any composites that were created from zero images.
2408
+ final_collection = yearly_collection.filter(ee.Filter.gt('image_count', 0))
2409
+
2410
+ self._yearly_min = LandsatCollection(collection=final_collection)
2411
+ else:
2412
+ pass
2413
+ return self._yearly_min
2414
+
2415
+ def yearly_sum_collection(self, start_month=1, end_month=12):
2416
+ """
2417
+ Creates a yearly sum composite from the collection, with optional monthly filtering.
2418
+
2419
+ This function computes the sum for each year within the collection's date range.
2420
+ You can specify a range of months (e.g., start_month=6, end_month=10 for June-October)
2421
+ to calculate the sum only using imagery from that specific season for each year.
2422
+ The resulting images have 'system:time_start', 'year', 'image_count', 'season_start',
2423
+ 'season_end', and 'Date_Filter' properties. Years with no images (after filtering) are excluded.
2424
+
2425
+ Args:
2426
+ start_month (int): The starting month (1-12) for the filter. Defaults to 1 (January).
2427
+ end_month (int): The ending month (1-12) for the filter. Defaults to 12 (December).
2428
+
2429
+ Returns:
2430
+ Object: A new instance of the same class (e.g., LandsatCollection) containing the yearly sum composites.
2431
+ """
2432
+ if self._yearly_sum is None:
2433
+
2434
+ date_range = self.collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
2435
+ start_date_full = ee.Date(date_range.get('min'))
2436
+ end_date_full = ee.Date(date_range.get('max'))
2437
+
2438
+ start_year = start_date_full.get('year')
2439
+ end_year = end_date_full.get('year')
2440
+
2441
+ if start_month != 1 or end_month != 12:
2442
+ processing_collection = self.collection.filter(ee.Filter.calendarRange(start_month, end_month, 'month'))
2443
+ else:
2444
+ processing_collection = self.collection
2445
+
2446
+ # Capture projection from the first image to restore it after reduction
2447
+ target_proj = self.collection.first().projection()
2448
+
2449
+ years = ee.List.sequence(start_year, end_year)
2450
+
2451
+ def create_yearly_composite(year):
2452
+ year = ee.Number(year)
2453
+ # Define the full calendar year range
2454
+ start_of_year = ee.Date.fromYMD(year, 1, 1)
2455
+ end_of_year = start_of_year.advance(1, 'year')
2456
+
2457
+ # Filter to the specific year using the PRE-FILTERED seasonal collection
2458
+ yearly_subset = processing_collection.filterDate(start_of_year, end_of_year)
2459
+
2460
+ # Calculate stats
2461
+ image_count = yearly_subset.size()
2462
+ yearly_reduction = yearly_subset.sum()
2463
+
2464
+ # Define the timestamp for the composite.
2465
+ # We use the start_month of that year to accurately reflect the data start time.
2466
+ composite_date = ee.Date.fromYMD(year, start_month, 1)
2467
+
2468
+ return yearly_reduction.set({
2469
+ 'system:time_start': composite_date.millis(),
2470
+ 'year': year,
2471
+ 'month': start_month,
2472
+ 'Date_Filter': composite_date.format('YYYY-MM-dd'),
2473
+ 'image_count': image_count,
2474
+ 'season_start': start_month,
2475
+ 'season_end': end_month
2476
+ }).reproject(target_proj)
2477
+
2478
+ # Map the function over the years list
2479
+ yearly_composites_list = years.map(create_yearly_composite)
2480
+
2481
+ # Convert to Collection
2482
+ yearly_collection = ee.ImageCollection.fromImages(yearly_composites_list)
2483
+
2484
+ # Filter out any composites that were created from zero images.
2485
+ final_collection = yearly_collection.filter(ee.Filter.gt('image_count', 0))
2486
+
2487
+ self._yearly_sum = LandsatCollection(collection=final_collection)
2488
+ else:
2489
+ pass
2490
+ return self._yearly_sum
2095
2491
 
2096
2492
  @property
2097
2493
  def ndwi(self):
@@ -2963,20 +3359,24 @@ class LandsatCollection:
2963
3359
  if classify_above_threshold:
2964
3360
  if mask_zeros is True:
2965
3361
  col = self.collection.map(
2966
- lambda image: image.select(band_name).gte(threshold).rename(band_name).selfMask().copyProperties(image)
3362
+ lambda image: image.select(band_name).gte(threshold).rename(band_name).selfMask()
3363
+ .copyProperties(image).set('system:time_start', image.get('system:time_start'))
2967
3364
  )
2968
3365
  else:
2969
3366
  col = self.collection.map(
2970
- lambda image: image.select(band_name).gte(threshold).rename(band_name).copyProperties(image)
3367
+ lambda image: image.select(band_name).gte(threshold).rename(band_name)
3368
+ .copyProperties(image).set('system:time_start', image.get('system:time_start'))
2971
3369
  )
2972
3370
  else:
2973
3371
  if mask_zeros is True:
2974
3372
  col = self.collection.map(
2975
- lambda image: image.select(band_name).lte(threshold).rename(band_name).selfMask().copyProperties(image)
3373
+ lambda image: image.select(band_name).lte(threshold).rename(band_name).selfMask()
3374
+ .copyProperties(image).set('system:time_start', image.get('system:time_start'))
2976
3375
  )
2977
3376
  else:
2978
3377
  col = self.collection.map(
2979
- lambda image: image.select(band_name).lte(threshold).rename(band_name).copyProperties(image)
3378
+ lambda image: image.select(band_name).lte(threshold).rename(band_name)
3379
+ .copyProperties(image).set('system:time_start', image.get('system:time_start'))
2980
3380
  )
2981
3381
 
2982
3382
  return LandsatCollection(collection=col)
@@ -3017,6 +3417,233 @@ class LandsatCollection:
3017
3417
  col = self.collection.map(lambda image: LandsatCollection.anomaly_fn(image, geometry=geometry, band_name=band_name, anomaly_band_name=anomaly_band_name, replace=replace, scale=scale))
3018
3418
  return LandsatCollection(collection=col)
3019
3419
 
3420
+ def mann_kendall_trend(self, target_band=None, join_method='system:time_start', geometry=None):
3421
+ """
3422
+ Calculates the Mann-Kendall S-value, Variance, Z-Score, and Confidence Level for each pixel in the image collection, in addition to calculating
3423
+ the Sen's slope for each pixel in the image collection. The output is an image with the following bands: 's_statistic', 'variance', 'z_score', 'confidence', and 'slope'.
3424
+
3425
+ This function can be used to identify trends in the image collection over time, such as increasing or decreasing values in the target band, and can be used to assess the significance of these trends.
3426
+ Note that this function is computationally intensive and may take a long time to run for large image collections or high-resolution images.
3427
+
3428
+ The 's_statistic' band represents the Mann-Kendall S-value, which is a measure of the strength and direction of the trend.
3429
+ The 'variance' band represents the variance of the S-value, which is a measure of the variability of the S-value.
3430
+ The 'z_score' band represents the Z-Score, which is a measure of the significance of the trend.
3431
+ The 'confidence' band represents the confidence level of the trend based on the z_score, which is a probabilistic measure of the confidence in the trend (percentage).
3432
+ The 'slope' band represents the Sen's slope, which is a measure of the rate of change in the target band over time. This value can be small as multispectral indices commonly range from -1 to 1, so a slope may have values of <0.2 for most cases.
3433
+
3434
+ Be sure to select the correct band for the `target_band` parameter, as this will be used to calculate the trend statistics.
3435
+ You may optionally provide an ee.Geometry object for the `geometry` parameter to limit the area over which the trend statistics are calculated.
3436
+ The `geometry` parameter is optional and defaults to None, which means that the trend statistics will be calculated over the entire footprint of the image collection.
3437
+
3438
+ Args:
3439
+ image_collection (LandsatCollection or ee.ImageCollection): The input image collection for which the Mann-Kendall and Sen's slope trend statistics will be calculated.
3440
+ target_band (str): The band name to be used for the output anomaly image. e.g. 'ndvi'
3441
+ join_method (str, optional): The method used to join images in the collection. Options are 'system:time_start' or 'Date_Filter'. Default is 'system:time_start'.
3442
+ geometry (ee.Geometry, optional): An ee.Geometry object to limit the area over which the trend statistics are calculated and mask the output image. Default is None.
3443
+
3444
+ Returns:
3445
+ ee.Image: An image with the following bands: 's_statistic', 'variance', 'z_score', 'confidence', and 'slope'.
3446
+ """
3447
+ ########## PART 1 - S-VALUE CALCULATION ##########
3448
+ ##### https://vsp.pnnl.gov/help/vsample/design_trend_mann_kendall.htm #####
3449
+ image_collection = self
3450
+ if isinstance(image_collection, LandsatCollection):
3451
+ image_collection = image_collection.collection
3452
+ elif isinstance(image_collection, ee.ImageCollection):
3453
+ pass
3454
+ else:
3455
+ raise ValueError(f'The chosen `image_collection`: {image_collection} is not a valid LandsatCollection or ee.ImageCollection object.')
3456
+
3457
+ if target_band is None:
3458
+ raise ValueError('The `target_band` parameter must be specified.')
3459
+ if not isinstance(target_band, str):
3460
+ raise ValueError(f'The chosen `target_band`: {target_band} is not a valid string.')
3461
+
3462
+ if geometry is not None and not isinstance(geometry, ee.Geometry):
3463
+ raise ValueError(f'The chosen `geometry`: {geometry} is not a valid ee.Geometry object.')
3464
+ # define the join, which will join all images newer than the current image
3465
+ # use system:time_start if the image does not have a Date_Filter property
3466
+ if join_method == 'system:time_start':
3467
+ # get all images where the leftField value is less than (before) the rightField value
3468
+ time_filter = ee.Filter.lessThan(leftField='system:time_start',
3469
+ rightField='system:time_start')
3470
+ elif join_method == 'Date_Filter':
3471
+ # get all images where the leftField value is less than (before) the rightField value
3472
+ time_filter = ee.Filter.lessThan(leftField='Date_Filter',
3473
+ rightField='Date_Filter')
3474
+ else:
3475
+ raise ValueError(f'The chosen `join_method`: {join_method} does not match the options of "system:time_start" or "Date_Filter".')
3476
+
3477
+ # for any matches during a join, set image as a property key called 'future_image'
3478
+ join = ee.Join.saveAll(matchesKey='future_image')
3479
+
3480
+ # apply the join on the input collection
3481
+ # joining all images newer than the current image with the current image
3482
+ joined_collection = ee.ImageCollection(join.apply(primary=image_collection,
3483
+ secondary=image_collection, condition=time_filter))
3484
+
3485
+ # defining a collection to calculate the partial S value for each match in the join
3486
+ # e.g. t4-t1, t3-t1, t2-1 if there are 4 images
3487
+ def calculate_partial_s(current_image):
3488
+ # select the target band for arithmetic
3489
+ current_val = current_image.select(target_band)
3490
+ # get the joined images from the current image properties and cast the joined images as a list
3491
+ future_image_list = ee.List(current_image.get('future_image'))
3492
+ # convert the joined list to an image collection
3493
+ future_image_collection = ee.ImageCollection(future_image_list)
3494
+
3495
+ # define a function that will calculate the difference between the joined images and the current image,
3496
+ # then calculate the partial S sign based on the value of the difference calculation
3497
+ def get_sign(future_image):
3498
+ # select the target band for arithmetic from the future image
3499
+ future_val = future_image.select(target_band)
3500
+ # calculate the difference, i.e. t2-t1
3501
+ difference = future_val.subtract(current_val)
3502
+ # determine the sign of the difference value (1 if diff > 0, 0 if 0, and -1 if diff < 0)
3503
+ # use .unmask(0) to set any masked pixels as 0 to avoid
3504
+
3505
+ sign = difference.signum().unmask(0)
3506
+
3507
+ return sign
3508
+
3509
+ # map the get_sign() function along the future image col
3510
+ # then sum the values for each pixel to get the partial S value
3511
+ return future_image_collection.map(get_sign).sum()
3512
+
3513
+ # calculate the partial s value for each image in the joined/input image collection
3514
+ partial_s_col = joined_collection.map(calculate_partial_s)
3515
+
3516
+ # convert the image collection to an image of s_statistic values per pixel
3517
+ # where the s_statistic is the sum of partial s values
3518
+ # renaming the band as 's_statistic' for later usage
3519
+ final_s_image = partial_s_col.sum().rename('s_statistic')
3520
+
3521
+
3522
+ ########## PART 2 - VARIANCE and Z-SCORE ##########
3523
+ # to calculate variance we need to know how many pixels were involved in the partial_s calculations per pixel
3524
+ # we do this by using count() and turn the value to a float for later arithmetic
3525
+ n = image_collection.select(target_band).count().toFloat()
3526
+
3527
+ ##### VARIANCE CALCULATION #####
3528
+ # as we are using floating point values with high precision, it is HIGHLY
3529
+ # unlikely that there will be multiple pixel values with the same value.
3530
+ # Thus, we opt to use the simplified variance calculation approach as the
3531
+ # impacts to the output value are negligible and the processing benefits are HUGE
3532
+ # variance = (n * (n - 1) * (2n + 5)) / 18
3533
+ var_s = n.multiply(n.subtract(1))\
3534
+ .multiply(n.multiply(2).add(5))\
3535
+ .divide(18).rename('variance')
3536
+
3537
+ z_score = ee.Image().expression(
3538
+ """
3539
+ (s > 0) ? (s - 1) / sqrt(var) :
3540
+ (s < 0) ? (s + 1) / sqrt(var) :
3541
+ 0
3542
+ """,
3543
+ {'s': final_s_image, 'var': var_s}
3544
+ ).rename('z_score')
3545
+
3546
+ confidence = z_score.abs().divide(ee.Number(2).sqrt()).erf().rename('confidence')
3547
+
3548
+ stat_bands = ee.Image([var_s, z_score, confidence])
3549
+
3550
+ mk_stats_image = final_s_image.addBands(stat_bands)
3551
+
3552
+ ########## PART 3 - Sen's Slope ##########
3553
+ def add_year_band(image):
3554
+ if join_method == 'Date_Filter':
3555
+ # Get the string 'YYYY-MM-DD'
3556
+ date_string = image.get('Date_Filter')
3557
+ # Parse it into an ee.Date object (handles the conversion to time math)
3558
+ date = ee.Date.parse('YYYY-MM-dd', date_string)
3559
+ else:
3560
+ # Standard way: assumes system:time_start exists
3561
+ date = image.date()
3562
+ years = date.difference(ee.Date('1970-01-01'), 'year')
3563
+ return image.addBands(ee.Image(years).float().rename('year'))
3564
+
3565
+ slope_input = image_collection.map(add_year_band).select(['year', target_band])
3566
+
3567
+ sens_slope = slope_input.reduce(ee.Reducer.sensSlope())
3568
+
3569
+ slope_band = sens_slope.select('slope')
3570
+
3571
+ # add a mask to the final image to remove pixels with less than min_observations
3572
+ # mainly an effort to mask pixels outside of the boundary of the input image collection
3573
+ min_observations = 1
3574
+ valid_mask = n.gte(min_observations)
3575
+
3576
+ final_image = mk_stats_image.addBands(slope_band).updateMask(valid_mask)
3577
+
3578
+ if geometry is not None:
3579
+ mask = ee.Image(1).clip(geometry)
3580
+ final_image = final_image.updateMask(mask)
3581
+
3582
+ return final_image
3583
+
3584
+ def sens_slope_trend(self, target_band=None, join_method='system:time_start', geometry=None):
3585
+ """
3586
+ Calculates Sen's Slope (trend magnitude) for the collection.
3587
+ This is a lighter-weight alternative to the full `mann_kendall_trend` function if only
3588
+ the direction and magnitude of the trend are needed.
3589
+
3590
+ Be sure to select the correct band for the `target_band` parameter, as this will be used to calculate the trend statistics.
3591
+ You may optionally provide an ee.Geometry object for the `geometry` parameter to limit the area over which the trend statistics are calculated.
3592
+ The `geometry` parameter is optional and defaults to None, which means that the trend statistics will be calculated over the entire footprint of the image collection.
3593
+
3594
+ Args:
3595
+ target_band (str): The name of the band to analyze. Defaults to 'ndvi'.
3596
+ join_method (str): Property to use for time sorting ('system:time_start' or 'Date_Filter').
3597
+ geometry (ee.Geometry, optional): Geometry to mask the final output.
3598
+
3599
+ Returns:
3600
+ ee.Image: An image containing the 'slope' band.
3601
+ """
3602
+ image_collection = self
3603
+ if isinstance(image_collection, LandsatCollection):
3604
+ image_collection = image_collection.collection
3605
+ elif isinstance(image_collection, ee.ImageCollection):
3606
+ pass
3607
+ else:
3608
+ raise ValueError(f'The chosen `image_collection`: {image_collection} is not a valid LandsatCollection or ee.ImageCollection object.')
3609
+
3610
+ if target_band is None:
3611
+ raise ValueError('The `target_band` parameter must be specified.')
3612
+ if not isinstance(target_band, str):
3613
+ raise ValueError(f'The chosen `target_band`: {target_band} is not a valid string.')
3614
+
3615
+ if geometry is not None and not isinstance(geometry, ee.Geometry):
3616
+ raise ValueError(f'The chosen `geometry`: {geometry} is not a valid ee.Geometry object.')
3617
+
3618
+ # Add Year Band (Time X-Axis)
3619
+ def add_year_band(image):
3620
+ # Handle user-defined date strings vs system time
3621
+ if join_method == 'Date_Filter':
3622
+ date_string = image.get('Date_Filter')
3623
+ date = ee.Date.parse('YYYY-MM-dd', date_string)
3624
+ else:
3625
+ date = image.date()
3626
+
3627
+ # Convert to fractional years relative to epoch
3628
+ years = date.difference(ee.Date('1970-01-01'), 'year')
3629
+ return image.addBands(ee.Image(years).float().rename('year'))
3630
+
3631
+ # Prepare Collection: Select ONLY [Year, Target]
3632
+ # sensSlope expects Band 0 = Independent (X), Band 1 = Dependent (Y)
3633
+ slope_input = self.collection.map(add_year_band).select(['year', target_band])
3634
+
3635
+ # Run the Native Reducer
3636
+ sens_result = slope_input.reduce(ee.Reducer.sensSlope())
3637
+
3638
+ # Extract and Mask
3639
+ slope_band = sens_result.select('slope')
3640
+
3641
+ if geometry is not None:
3642
+ mask = ee.Image(1).clip(geometry)
3643
+ slope_band = slope_band.updateMask(mask)
3644
+
3645
+ return slope_band
3646
+
3020
3647
  def mask_via_band(self, band_to_mask, band_for_mask, threshold=-1, mask_above=True, add_band_to_original_image=False):
3021
3648
  """
3022
3649
  Masks select pixels of a selected band from an image based on another specified band and threshold (optional).
@@ -3099,7 +3726,8 @@ class LandsatCollection:
3099
3726
  )
3100
3727
 
3101
3728
  # guarantee single band + keep properties
3102
- out = ee.Image(out).select([band_name_to_mask]).copyProperties(prim, prim.propertyNames())
3729
+ out = ee.Image(out).select([band_name_to_mask]).copyProperties(prim, prim.propertyNames())\
3730
+ .set('system:time_start', prim.get('system:time_start'))
3103
3731
  out = out.set('Date_Filter', prim.get('Date_Filter'))
3104
3732
  return ee.Image(out) # <-- return as Image
3105
3733