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.
@@ -170,6 +170,11 @@ class Sentinel2Collection:
170
170
  self._monthly_max = None
171
171
  self._monthly_min = None
172
172
  self._monthly_sum = None
173
+ self._yearly_median = None
174
+ self._yearly_mean = None
175
+ self._yearly_max = None
176
+ self._yearly_min = None
177
+ self._yearly_sum = None
173
178
  self._mean = None
174
179
  self._max = None
175
180
  self._min = None
@@ -223,7 +228,7 @@ class Sentinel2Collection:
223
228
  water = (
224
229
  ndwi_calc.updateMask(ndwi_calc.gte(threshold))
225
230
  .rename("ndwi")
226
- .copyProperties(image)
231
+ .copyProperties(image).set("threshold", threshold, "system:time_start", image.get("system:time_start"))
227
232
  )
228
233
  return water
229
234
 
@@ -245,7 +250,7 @@ class Sentinel2Collection:
245
250
  water = (
246
251
  mndwi_calc.updateMask(mndwi_calc.gte(threshold))
247
252
  .rename("mndwi")
248
- .copyProperties(image)
253
+ .copyProperties(image).set("threshold", threshold, "system:time_start", image.get("system:time_start"))
249
254
  )
250
255
  return water
251
256
 
@@ -267,7 +272,7 @@ class Sentinel2Collection:
267
272
  vegetation = (
268
273
  ndvi_calc.updateMask(ndvi_calc.gte(threshold))
269
274
  .rename("ndvi")
270
- .copyProperties(image)
275
+ .copyProperties(image).set("threshold", threshold, "system:time_start", image.get("system:time_start"))
271
276
  ) # subsets the image to just water pixels, 0.2 threshold for datasets
272
277
  return vegetation
273
278
 
@@ -293,9 +298,9 @@ class Sentinel2Collection:
293
298
  # If spacecraft is Landsat 5 TM, use the correct expression,
294
299
  # otherwise treat as OLI and copy properties after renaming band to "albedo"
295
300
  if snow_free == True:
296
- albedo = image.expression(MSI_expression_snow_free).rename("albedo").copyProperties(image)
301
+ albedo = image.expression(MSI_expression_snow_free).rename("albedo").copyProperties(image).set("system:time_start", image.get("system:time_start"))
297
302
  elif snow_free == False:
298
- albedo = image.expression(MSI_expression_snow_included).rename("albedo").copyProperties(image)
303
+ albedo = image.expression(MSI_expression_snow_included).rename("albedo").copyProperties(image).set("system:time_start", image.get("system:time_start"))
299
304
  else:
300
305
  raise ValueError("snow_free argument must be True or False")
301
306
  return albedo
@@ -316,7 +321,7 @@ class Sentinel2Collection:
316
321
  halite = (
317
322
  halite_index.updateMask(halite_index.gte(threshold))
318
323
  .rename("halite")
319
- .copyProperties(image)
324
+ .copyProperties(image).set("threshold", threshold, "system:time_start", image.get("system:time_start"))
320
325
  )
321
326
  return halite
322
327
 
@@ -336,7 +341,7 @@ class Sentinel2Collection:
336
341
  gypsum = (
337
342
  gypsum_index.updateMask(gypsum_index.gte(threshold))
338
343
  .rename("gypsum")
339
- .copyProperties(image)
344
+ .copyProperties(image).set("threshold", threshold, "system:time_start", image.get("system:time_start"))
340
345
  )
341
346
  return gypsum
342
347
 
@@ -355,6 +360,7 @@ class Sentinel2Collection:
355
360
  NDTI = image.normalizedDifference(["B3", "B2"])
356
361
  turbidity = (
357
362
  NDTI.updateMask(NDTI.gte(threshold)).rename("ndti").copyProperties(image)
363
+ .set("threshold", threshold, "system:time_start", image.get("system:time_start"))
358
364
  )
359
365
  return turbidity
360
366
 
@@ -375,6 +381,7 @@ class Sentinel2Collection:
375
381
  chl_index.updateMask(chl_index.gte(threshold))
376
382
  .rename("2BDA")
377
383
  .copyProperties(image)
384
+ .set("threshold", threshold, "system:time_start", image.get("system:time_start"))
378
385
  )
379
386
  return chlorophyll
380
387
 
@@ -390,7 +397,9 @@ class Sentinel2Collection:
390
397
  ee.Image: NDSI ee.Image
391
398
  """
392
399
  ndsi_calc = image.normalizedDifference(["B3", "B11"])
393
- ndsi = ndsi_calc.updateMask(ndsi_calc.gte(threshold)).rename("ndsi").copyProperties(image).set("threshold", threshold)
400
+ ndsi = (ndsi_calc.updateMask(ndsi_calc.gte(threshold)).rename("ndsi")
401
+ .copyProperties(image)
402
+ .set("threshold", threshold, "system:time_start", image.get("system:time_start")))
394
403
  return ndsi
395
404
 
396
405
  @staticmethod
@@ -411,7 +420,10 @@ class Sentinel2Collection:
411
420
  """
412
421
  evi_expression = f'{gain_factor} * ((b("B8") - b("B4")) / (b("B8") + {c1} * b("B4") - {c2} * b("B2") + {l}))'
413
422
  evi_calc = image.expression(evi_expression)
414
- evi = evi_calc.updateMask(evi_calc.gte(threshold)).rename("evi").copyProperties(image).set("threshold", threshold)
423
+ evi = (evi_calc.updateMask(evi_calc.gte(threshold)).rename("evi")
424
+ .copyProperties(image)
425
+ .set("threshold", threshold, "system:time_start", image.get("system:time_start"))
426
+ )
415
427
  return evi
416
428
 
417
429
  @staticmethod
@@ -429,7 +441,9 @@ class Sentinel2Collection:
429
441
  """
430
442
  savi_expression = f'((b("B8") - b("B4")) / (b("B8") + b("B4") + {l})) * (1 + {l})'
431
443
  savi_calc = image.expression(savi_expression)
432
- savi = savi_calc.updateMask(savi_calc.gte(threshold)).rename("savi").copyProperties(image).set("threshold", threshold)
444
+ savi = (savi_calc.updateMask(savi_calc.gte(threshold)).rename("savi")
445
+ .copyProperties(image)
446
+ .set("threshold", threshold, "system:time_start", image.get("system:time_start")))
433
447
  return savi
434
448
 
435
449
  @staticmethod
@@ -447,7 +461,8 @@ class Sentinel2Collection:
447
461
  """
448
462
  msavi_expression = '0.5 * (2 * b("B8") + 1 - ((2 * b("B8") + 1) ** 2 - 8 * (b("B8") - b("B4"))) ** 0.5)'
449
463
  msavi_calc = image.expression(msavi_expression)
450
- msavi = msavi_calc.updateMask(msavi_calc.gte(threshold)).rename("msavi").copyProperties(image).set("threshold", threshold)
464
+ msavi = (msavi_calc.updateMask(msavi_calc.gte(threshold)).rename("msavi").copyProperties(image)
465
+ .set("threshold", threshold, "system:time_start", image.get("system:time_start")))
451
466
  return msavi
452
467
 
453
468
  @staticmethod
@@ -465,7 +480,10 @@ class Sentinel2Collection:
465
480
  """
466
481
  ndmi_expression = '(b("B8") - b("B11")) / (b("B8") + b("B11"))'
467
482
  ndmi_calc = image.expression(ndmi_expression)
468
- ndmi = ndmi_calc.updateMask(ndmi_calc.gte(threshold)).rename("ndmi").copyProperties(image).set("threshold", threshold)
483
+ ndmi = (ndmi_calc.updateMask(ndmi_calc.gte(threshold)).rename("ndmi")
484
+ .copyProperties(image)
485
+ .set("threshold", threshold, "system:time_start", image.get("system:time_start"))
486
+ )
469
487
  return ndmi
470
488
 
471
489
  @staticmethod
@@ -482,7 +500,9 @@ class Sentinel2Collection:
482
500
  """
483
501
  nbr_expression = '(b("B8") - b("B12")) / (b("B8") + b("B12"))'
484
502
  nbr_calc = image.expression(nbr_expression)
485
- nbr = nbr_calc.updateMask(nbr_calc.gte(threshold)).rename("nbr").copyProperties(image).set("threshold", threshold)
503
+ nbr = (nbr_calc.updateMask(nbr_calc.gte(threshold)).rename("nbr")
504
+ .copyProperties(image).set("threshold", threshold, "system:time_start", image.get("system:time_start"))
505
+ )
486
506
  return nbr
487
507
 
488
508
  @staticmethod
@@ -556,7 +576,7 @@ class Sentinel2Collection:
556
576
  """
557
577
  SCL = image.select("SCL")
558
578
  CloudMask = SCL.neq(9)
559
- return image.updateMask(CloudMask).copyProperties(image)
579
+ return image.updateMask(CloudMask).copyProperties(image).set('system:time_start', image.get('system:time_start'))
560
580
 
561
581
  @staticmethod
562
582
  def MaskShadowsS2(image):
@@ -571,7 +591,7 @@ class Sentinel2Collection:
571
591
  """
572
592
  SCL = image.select("SCL")
573
593
  ShadowMask = SCL.neq(3)
574
- return image.updateMask(ShadowMask).copyProperties(image)
594
+ return image.updateMask(ShadowMask).copyProperties(image).set('system:time_start', image.get('system:time_start'))
575
595
 
576
596
  @staticmethod
577
597
  def MaskWaterS2(image):
@@ -586,7 +606,7 @@ class Sentinel2Collection:
586
606
  """
587
607
  SCL = image.select("SCL")
588
608
  WaterMask = SCL.neq(6)
589
- return image.updateMask(WaterMask).copyProperties(image)
609
+ return image.updateMask(WaterMask).copyProperties(image).set('system:time_start', image.get('system:time_start'))
590
610
 
591
611
  @staticmethod
592
612
  def MaskWaterS2ByNDWI(image, threshold):
@@ -604,7 +624,7 @@ class Sentinel2Collection:
604
624
  ndwi_calc = image.normalizedDifference(
605
625
  ["B3", "B8"]
606
626
  ) # green-NIR / green+NIR -- full NDWI image
607
- water = image.updateMask(ndwi_calc.lt(threshold))
627
+ water = image.updateMask(ndwi_calc.lt(threshold)).copyProperties(image).set('system:time_start', image.get('system:time_start'))
608
628
  return water
609
629
 
610
630
  @staticmethod
@@ -620,7 +640,7 @@ class Sentinel2Collection:
620
640
  """
621
641
  SCL = image.select("SCL")
622
642
  WaterMask = SCL.eq(6)
623
- return image.updateMask(WaterMask).copyProperties(image)
643
+ return image.updateMask(WaterMask).copyProperties(image).set('system:time_start', image.get('system:time_start'))
624
644
 
625
645
  @staticmethod
626
646
  def halite_mask(image, threshold):
@@ -635,7 +655,7 @@ class Sentinel2Collection:
635
655
  ee.Image: ee.Image where halite pixels are masked (image without halite pixels).
636
656
  """
637
657
  halite_index = image.normalizedDifference(["B4", "B11"])
638
- mask = image.updateMask(halite_index.lt(threshold)).copyProperties(image)
658
+ mask = image.updateMask(halite_index.lt(threshold)).copyProperties(image).set('system:time_start', image.get('system:time_start'))
639
659
  return mask
640
660
 
641
661
  @staticmethod
@@ -659,6 +679,7 @@ class Sentinel2Collection:
659
679
  .updateMask(gypsum_index.lt(gypsum_threshold))
660
680
  .rename("carbonate_muds")
661
681
  .copyProperties(image)
682
+ .set('system:time_start', image.get('system:time_start'))
662
683
  )
663
684
  return mask
664
685
 
@@ -688,7 +709,7 @@ class Sentinel2Collection:
688
709
  if add_band_to_original_image:
689
710
  return image.addBands(band_to_mask_image.updateMask(mask).rename(band_to_mask), overwrite=True)
690
711
  else:
691
- return ee.Image(band_to_mask_image.updateMask(mask).rename(band_to_mask).copyProperties(image))
712
+ return ee.Image(band_to_mask_image.updateMask(mask).rename(band_to_mask).copyProperties(image).set('system:time_start', image.get('system:time_start')))
692
713
 
693
714
  @staticmethod
694
715
  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):
@@ -724,7 +745,7 @@ class Sentinel2Collection:
724
745
  mask = band_for_mask_image.gt(threshold)
725
746
  else:
726
747
  mask = band_for_mask_image.lt(threshold)
727
- return band_to_mask_image.updateMask(mask).rename(band_to_mask).copyProperties(image_to_mask)
748
+ 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'))
728
749
 
729
750
  @staticmethod
730
751
  def MaskToWaterS2ByNDWI(image, threshold):
@@ -741,7 +762,7 @@ class Sentinel2Collection:
741
762
  ndwi_calc = image.normalizedDifference(
742
763
  ["B3", "B8"]
743
764
  ) # green-NIR / green+NIR -- full NDWI image
744
- water = image.updateMask(ndwi_calc.gte(threshold))
765
+ water = image.updateMask(ndwi_calc.gte(threshold)).copyProperties(image).set('system:time_start', image.get('system:time_start'))
745
766
  return water
746
767
 
747
768
  @staticmethod
@@ -1698,6 +1719,391 @@ class Sentinel2Collection:
1698
1719
  pass
1699
1720
 
1700
1721
  return self._monthly_median
1722
+
1723
+ def yearly_mean_collection(self, start_month=1, end_month=12):
1724
+ """
1725
+ Creates a yearly mean composite from the collection, with optional monthly filtering.
1726
+
1727
+ This function computes the mean for each year within the collection's date range.
1728
+ You can specify a range of months (e.g., start_month=6, end_month=10 for June-October)
1729
+ to calculate the mean only using imagery from that specific season for each year.
1730
+
1731
+ The resulting images have 'system:time_start', 'year', 'image_count', 'season_start',
1732
+ 'season_end', and 'Date_Filter' properties. Years with no images (after filtering) are excluded.
1733
+
1734
+ Args:
1735
+ start_month (int): The starting month (1-12) for the filter. Defaults to 1 (January).
1736
+ end_month (int): The ending month (1-12) for the filter. Defaults to 12 (December).
1737
+
1738
+ Returns:
1739
+ Object: A new instance of the same class (e.g., Sentinel2Collection) containing the yearly mean composites.
1740
+ """
1741
+ if self._yearly_mean is None:
1742
+
1743
+ date_range = self.collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
1744
+ start_date_full = ee.Date(date_range.get('min'))
1745
+ end_date_full = ee.Date(date_range.get('max'))
1746
+
1747
+ start_year = start_date_full.get('year')
1748
+ end_year = end_date_full.get('year')
1749
+
1750
+ if start_month != 1 or end_month != 12:
1751
+ processing_collection = self.collection.filter(ee.Filter.calendarRange(start_month, end_month, 'month'))
1752
+ else:
1753
+ processing_collection = self.collection
1754
+
1755
+ # Capture projection from the first image to restore it after reduction
1756
+ target_proj = self.collection.first().projection()
1757
+
1758
+ years = ee.List.sequence(start_year, end_year)
1759
+
1760
+ def create_yearly_composite(year):
1761
+ year = ee.Number(year)
1762
+ # Define the full calendar year range
1763
+ start_of_year = ee.Date.fromYMD(year, 1, 1)
1764
+ end_of_year = start_of_year.advance(1, 'year')
1765
+
1766
+ yearly_subset = processing_collection.filterDate(start_of_year, end_of_year)
1767
+
1768
+ # Calculate stats
1769
+ image_count = yearly_subset.size()
1770
+ yearly_reduction = yearly_subset.mean()
1771
+
1772
+ # Define the timestamp for the composite.
1773
+ # We use the start_month of that year to accurately reflect the data start time.
1774
+ composite_date = ee.Date.fromYMD(year, start_month, 1)
1775
+
1776
+ return yearly_reduction.set({
1777
+ 'system:time_start': composite_date.millis(),
1778
+ 'year': year,
1779
+ 'month': start_month,
1780
+ 'Date_Filter': composite_date.format('YYYY-MM-dd'),
1781
+ 'image_count': image_count,
1782
+ 'season_start': start_month,
1783
+ 'season_end': end_month
1784
+ }).reproject(target_proj)
1785
+
1786
+ # Map the function over the years list
1787
+ yearly_composites_list = years.map(create_yearly_composite)
1788
+
1789
+ # Convert to Collection
1790
+ yearly_collection = ee.ImageCollection.fromImages(yearly_composites_list)
1791
+
1792
+ # Filter out any composites that were created from zero images.
1793
+ final_collection = yearly_collection.filter(ee.Filter.gt('image_count', 0))
1794
+
1795
+ self._yearly_mean = Sentinel2Collection(collection=final_collection)
1796
+ else:
1797
+ pass
1798
+ return self._yearly_mean
1799
+
1800
+ def yearly_median_collection(self, start_month=1, end_month=12):
1801
+ """
1802
+ Creates a yearly median composite from the collection, with optional monthly filtering.
1803
+
1804
+ This function computes the median for each year within the collection's date range.
1805
+ You can specify a range of months (e.g., start_month=6, end_month=10 for June-October)
1806
+ to calculate the median only using imagery from that specific season for each year.
1807
+ The resulting images have 'system:time_start', 'year', 'image_count', 'season_start',
1808
+ 'season_end', and 'Date_Filter' properties. Years with no images (after filtering) are excluded.
1809
+
1810
+ Args:
1811
+ start_month (int): The starting month (1-12) for the filter. Defaults to 1 (January).
1812
+ end_month (int): The ending month (1-12) for the filter. Defaults to 12 (December).
1813
+
1814
+ Returns:
1815
+ Object: A new instance of the same class (e.g., Sentinel2Collection) containing the yearly median composites.
1816
+ """
1817
+ if self._yearly_median is None:
1818
+
1819
+ date_range = self.collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
1820
+ start_date_full = ee.Date(date_range.get('min'))
1821
+ end_date_full = ee.Date(date_range.get('max'))
1822
+
1823
+ start_year = start_date_full.get('year')
1824
+ end_year = end_date_full.get('year')
1825
+
1826
+ if start_month != 1 or end_month != 12:
1827
+ processing_collection = self.collection.filter(ee.Filter.calendarRange(start_month, end_month, 'month'))
1828
+ else:
1829
+ processing_collection = self.collection
1830
+
1831
+ # Capture projection from the first image to restore it after reduction
1832
+ target_proj = self.collection.first().projection()
1833
+
1834
+ years = ee.List.sequence(start_year, end_year)
1835
+
1836
+ def create_yearly_composite(year):
1837
+ year = ee.Number(year)
1838
+ # Define the full calendar year range
1839
+ start_of_year = ee.Date.fromYMD(year, 1, 1)
1840
+ end_of_year = start_of_year.advance(1, 'year')
1841
+
1842
+ # Filter to the specific year using the PRE-FILTERED seasonal collection
1843
+ yearly_subset = processing_collection.filterDate(start_of_year, end_of_year)
1844
+
1845
+ # Calculate stats
1846
+ image_count = yearly_subset.size()
1847
+ yearly_reduction = yearly_subset.median()
1848
+
1849
+ # Define the timestamp for the composite.
1850
+ # We use the start_month of that year to accurately reflect the data start time.
1851
+ composite_date = ee.Date.fromYMD(year, start_month, 1)
1852
+
1853
+ return yearly_reduction.set({
1854
+ 'system:time_start': composite_date.millis(),
1855
+ 'year': year,
1856
+ 'month': start_month,
1857
+ 'Date_Filter': composite_date.format('YYYY-MM-dd'),
1858
+ 'image_count': image_count,
1859
+ 'season_start': start_month,
1860
+ 'season_end': end_month
1861
+ }).reproject(target_proj)
1862
+
1863
+ # Map the function over the years list
1864
+ yearly_composites_list = years.map(create_yearly_composite)
1865
+
1866
+ # Convert to Collection
1867
+ yearly_collection = ee.ImageCollection.fromImages(yearly_composites_list)
1868
+
1869
+ # Filter out any composites that were created from zero images.
1870
+ final_collection = yearly_collection.filter(ee.Filter.gt('image_count', 0))
1871
+
1872
+ self._yearly_median = Sentinel2Collection(collection=final_collection)
1873
+ else:
1874
+ pass
1875
+ return self._yearly_median
1876
+
1877
+ def yearly_max_collection(self, start_month=1, end_month=12):
1878
+ """
1879
+ Creates a yearly max composite from the collection, with optional monthly filtering.
1880
+
1881
+ This function computes the max for each year within the collection's date range.
1882
+ You can specify a range of months (e.g., start_month=6, end_month=10 for June-October)
1883
+ to calculate the max only using imagery from that specific season for each year.
1884
+ The resulting images have 'system:time_start', 'year', 'image_count', 'season_start',
1885
+ 'season_end', and 'Date_Filter' properties. Years with no images (after filtering) are excluded.
1886
+
1887
+ Args:
1888
+ start_month (int): The starting month (1-12) for the filter. Defaults to 1 (January).
1889
+ end_month (int): The ending month (1-12) for the filter. Defaults to 12 (December).
1890
+
1891
+ Returns:
1892
+ Object: A new instance of the same class (e.g., Sentinel2Collection) containing the yearly max composites.
1893
+ """
1894
+ if self._yearly_max is None:
1895
+
1896
+ date_range = self.collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
1897
+ start_date_full = ee.Date(date_range.get('min'))
1898
+ end_date_full = ee.Date(date_range.get('max'))
1899
+
1900
+ start_year = start_date_full.get('year')
1901
+ end_year = end_date_full.get('year')
1902
+
1903
+ if start_month != 1 or end_month != 12:
1904
+ processing_collection = self.collection.filter(ee.Filter.calendarRange(start_month, end_month, 'month'))
1905
+ else:
1906
+ processing_collection = self.collection
1907
+
1908
+ # Capture projection from the first image to restore it after reduction
1909
+ target_proj = self.collection.first().projection()
1910
+
1911
+ years = ee.List.sequence(start_year, end_year)
1912
+
1913
+ def create_yearly_composite(year):
1914
+ year = ee.Number(year)
1915
+ # Define the full calendar year range
1916
+ start_of_year = ee.Date.fromYMD(year, 1, 1)
1917
+ end_of_year = start_of_year.advance(1, 'year')
1918
+
1919
+ # Filter to the specific year using the PRE-FILTERED seasonal collection
1920
+ yearly_subset = processing_collection.filterDate(start_of_year, end_of_year)
1921
+
1922
+ # Calculate stats
1923
+ image_count = yearly_subset.size()
1924
+ yearly_reduction = yearly_subset.max()
1925
+
1926
+ # Define the timestamp for the composite.
1927
+ # We use the start_month of that year to accurately reflect the data start time.
1928
+ composite_date = ee.Date.fromYMD(year, start_month, 1)
1929
+
1930
+ return yearly_reduction.set({
1931
+ 'system:time_start': composite_date.millis(),
1932
+ 'year': year,
1933
+ 'month': start_month,
1934
+ 'Date_Filter': composite_date.format('YYYY-MM-dd'),
1935
+ 'image_count': image_count,
1936
+ 'season_start': start_month,
1937
+ 'season_end': end_month
1938
+ }).reproject(target_proj)
1939
+
1940
+ # Map the function over the years list
1941
+ yearly_composites_list = years.map(create_yearly_composite)
1942
+
1943
+ # Convert to Collection
1944
+ yearly_collection = ee.ImageCollection.fromImages(yearly_composites_list)
1945
+
1946
+ # Filter out any composites that were created from zero images.
1947
+ final_collection = yearly_collection.filter(ee.Filter.gt('image_count', 0))
1948
+
1949
+ self._yearly_max = Sentinel2Collection(collection=final_collection)
1950
+ else:
1951
+ pass
1952
+ return self._yearly_max
1953
+
1954
+ def yearly_min_collection(self, start_month=1, end_month=12):
1955
+ """
1956
+ Creates a yearly min composite from the collection, with optional monthly filtering.
1957
+
1958
+ This function computes the min for each year within the collection's date range.
1959
+ You can specify a range of months (e.g., start_month=6, end_month=10 for June-October)
1960
+ to calculate the min only using imagery from that specific season for each year.
1961
+ The resulting images have 'system:time_start', 'year', 'image_count', 'season_start',
1962
+ 'season_end', and 'Date_Filter' properties. Years with no images (after filtering) are excluded.
1963
+
1964
+ Args:
1965
+ start_month (int): The starting month (1-12) for the filter. Defaults to 1 (January).
1966
+ end_month (int): The ending month (1-12) for the filter. Defaults to 12 (December).
1967
+
1968
+ Returns:
1969
+ Object: A new instance of the same class (e.g., Sentinel2Collection) containing the yearly min composites.
1970
+ """
1971
+ if self._yearly_min is None:
1972
+
1973
+ date_range = self.collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
1974
+ start_date_full = ee.Date(date_range.get('min'))
1975
+ end_date_full = ee.Date(date_range.get('max'))
1976
+
1977
+ start_year = start_date_full.get('year')
1978
+ end_year = end_date_full.get('year')
1979
+
1980
+ if start_month != 1 or end_month != 12:
1981
+ processing_collection = self.collection.filter(ee.Filter.calendarRange(start_month, end_month, 'month'))
1982
+ else:
1983
+ processing_collection = self.collection
1984
+
1985
+ # Capture projection from the first image to restore it after reduction
1986
+ target_proj = self.collection.first().projection()
1987
+
1988
+ years = ee.List.sequence(start_year, end_year)
1989
+
1990
+ def create_yearly_composite(year):
1991
+ year = ee.Number(year)
1992
+ # Define the full calendar year range
1993
+ start_of_year = ee.Date.fromYMD(year, 1, 1)
1994
+ end_of_year = start_of_year.advance(1, 'year')
1995
+
1996
+ # Filter to the specific year using the PRE-FILTERED seasonal collection
1997
+ yearly_subset = processing_collection.filterDate(start_of_year, end_of_year)
1998
+
1999
+ # Calculate stats
2000
+ image_count = yearly_subset.size()
2001
+ yearly_reduction = yearly_subset.min()
2002
+
2003
+ # Define the timestamp for the composite.
2004
+ # We use the start_month of that year to accurately reflect the data start time.
2005
+ composite_date = ee.Date.fromYMD(year, start_month, 1)
2006
+
2007
+ return yearly_reduction.set({
2008
+ 'system:time_start': composite_date.millis(),
2009
+ 'year': year,
2010
+ 'month': start_month,
2011
+ 'Date_Filter': composite_date.format('YYYY-MM-dd'),
2012
+ 'image_count': image_count,
2013
+ 'season_start': start_month,
2014
+ 'season_end': end_month
2015
+ }).reproject(target_proj)
2016
+
2017
+ # Map the function over the years list
2018
+ yearly_composites_list = years.map(create_yearly_composite)
2019
+
2020
+ # Convert to Collection
2021
+ yearly_collection = ee.ImageCollection.fromImages(yearly_composites_list)
2022
+
2023
+ # Filter out any composites that were created from zero images.
2024
+ final_collection = yearly_collection.filter(ee.Filter.gt('image_count', 0))
2025
+
2026
+ self._yearly_min = Sentinel2Collection(collection=final_collection)
2027
+ else:
2028
+ pass
2029
+ return self._yearly_min
2030
+
2031
+ def yearly_sum_collection(self, start_month=1, end_month=12):
2032
+ """
2033
+ Creates a yearly sum composite from the collection, with optional monthly filtering.
2034
+
2035
+ This function computes the sum for each year within the collection's date range.
2036
+ You can specify a range of months (e.g., start_month=6, end_month=10 for June-October)
2037
+ to calculate the sum only using imagery from that specific season for each year.
2038
+ The resulting images have 'system:time_start', 'year', 'image_count', 'season_start',
2039
+ 'season_end', and 'Date_Filter' properties. Years with no images (after filtering) are excluded.
2040
+
2041
+ Args:
2042
+ start_month (int): The starting month (1-12) for the filter. Defaults to 1 (January).
2043
+ end_month (int): The ending month (1-12) for the filter. Defaults to 12 (December).
2044
+
2045
+ Returns:
2046
+ Object: A new instance of the same class (e.g., Sentinel2Collection) containing the yearly sum composites.
2047
+ """
2048
+ if self._yearly_sum is None:
2049
+
2050
+ date_range = self.collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
2051
+ start_date_full = ee.Date(date_range.get('min'))
2052
+ end_date_full = ee.Date(date_range.get('max'))
2053
+
2054
+ start_year = start_date_full.get('year')
2055
+ end_year = end_date_full.get('year')
2056
+
2057
+ if start_month != 1 or end_month != 12:
2058
+ processing_collection = self.collection.filter(ee.Filter.calendarRange(start_month, end_month, 'month'))
2059
+ else:
2060
+ processing_collection = self.collection
2061
+
2062
+ # Capture projection from the first image to restore it after reduction
2063
+ target_proj = self.collection.first().projection()
2064
+
2065
+ years = ee.List.sequence(start_year, end_year)
2066
+
2067
+ def create_yearly_composite(year):
2068
+ year = ee.Number(year)
2069
+ # Define the full calendar year range
2070
+ start_of_year = ee.Date.fromYMD(year, 1, 1)
2071
+ end_of_year = start_of_year.advance(1, 'year')
2072
+
2073
+ # Filter to the specific year using the PRE-FILTERED seasonal collection
2074
+ yearly_subset = processing_collection.filterDate(start_of_year, end_of_year)
2075
+
2076
+ # Calculate stats
2077
+ image_count = yearly_subset.size()
2078
+ yearly_reduction = yearly_subset.sum()
2079
+
2080
+ # Define the timestamp for the composite.
2081
+ # We use the start_month of that year to accurately reflect the data start time.
2082
+ composite_date = ee.Date.fromYMD(year, start_month, 1)
2083
+
2084
+ return yearly_reduction.set({
2085
+ 'system:time_start': composite_date.millis(),
2086
+ 'year': year,
2087
+ 'month': start_month,
2088
+ 'Date_Filter': composite_date.format('YYYY-MM-dd'),
2089
+ 'image_count': image_count,
2090
+ 'season_start': start_month,
2091
+ 'season_end': end_month
2092
+ }).reproject(target_proj)
2093
+
2094
+ # Map the function over the years list
2095
+ yearly_composites_list = years.map(create_yearly_composite)
2096
+
2097
+ # Convert to Collection
2098
+ yearly_collection = ee.ImageCollection.fromImages(yearly_composites_list)
2099
+
2100
+ # Filter out any composites that were created from zero images.
2101
+ final_collection = yearly_collection.filter(ee.Filter.gt('image_count', 0))
2102
+
2103
+ self._yearly_sum = Sentinel2Collection(collection=final_collection)
2104
+ else:
2105
+ pass
2106
+ return self._yearly_sum
1701
2107
 
1702
2108
  @property
1703
2109
  def ndwi(self):
@@ -2465,20 +2871,28 @@ class Sentinel2Collection:
2465
2871
  if classify_above_threshold:
2466
2872
  if mask_zeros is True:
2467
2873
  col = self.collection.map(
2468
- lambda image: image.select(band_name).gte(threshold).rename(band_name).selfMask().copyProperties(image)
2874
+ lambda image: image.select(band_name).gte(threshold)
2875
+ .rename(band_name).selfMask().copyProperties(image)
2876
+ .set('system:time_start', image.get('system:time_start'))
2469
2877
  )
2470
2878
  else:
2471
2879
  col = self.collection.map(
2472
- lambda image: image.select(band_name).gte(threshold).rename(band_name).copyProperties(image)
2880
+ lambda image: image.select(band_name).gte(threshold)
2881
+ .rename(band_name).copyProperties(image)
2882
+ .set('system:time_start', image.get('system:time_start'))
2473
2883
  )
2474
2884
  else:
2475
2885
  if mask_zeros is True:
2476
2886
  col = self.collection.map(
2477
- lambda image: image.select(band_name).lte(threshold).rename(band_name).selfMask().copyProperties(image)
2887
+ lambda image: image.select(band_name).lte(threshold)
2888
+ .rename(band_name).selfMask().copyProperties(image)
2889
+ .set('system:time_start', image.get('system:time_start'))
2478
2890
  )
2479
2891
  else:
2480
2892
  col = self.collection.map(
2481
- lambda image: image.select(band_name).lte(threshold).rename(band_name).copyProperties(image)
2893
+ lambda image: image.select(band_name).lte(threshold)
2894
+ .rename(band_name).copyProperties(image)
2895
+ .set('system:time_start', image.get('system:time_start'))
2482
2896
  )
2483
2897
 
2484
2898
  return Sentinel2Collection(collection=col)
@@ -2519,6 +2933,234 @@ class Sentinel2Collection:
2519
2933
  col = self.collection.map(lambda image: Sentinel2Collection.anomaly_fn(image, geometry=geometry, band_name=band_name, anomaly_band_name=anomaly_band_name, replace=replace, scale=scale))
2520
2934
  return Sentinel2Collection(collection=col)
2521
2935
 
2936
+ def mann_kendall_trend(self, target_band=None, join_method='system:time_start', geometry=None):
2937
+ """
2938
+ Calculates the Mann-Kendall S-value, Variance, Z-Score, and Confidence Level for each pixel in the image collection, in addition to calculating
2939
+ 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'.
2940
+
2941
+ 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.
2942
+ Note that this function is computationally intensive and may take a long time to run for large image collections or high-resolution images.
2943
+
2944
+ The 's_statistic' band represents the Mann-Kendall S-value, which is a measure of the strength and direction of the trend.
2945
+ The 'variance' band represents the variance of the S-value, which is a measure of the variability of the S-value.
2946
+ The 'z_score' band represents the Z-Score, which is a measure of the significance of the trend.
2947
+ 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).
2948
+ 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.
2949
+
2950
+ Be sure to select the correct band for the `target_band` parameter, as this will be used to calculate the trend statistics.
2951
+ You may optionally provide an ee.Geometry object for the `geometry` parameter to limit the area over which the trend statistics are calculated.
2952
+ 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.
2953
+
2954
+ Args:
2955
+ image_collection (Sentinel2Collection or ee.ImageCollection): The input image collection for which the Mann-Kendall and Sen's slope trend statistics will be calculated.
2956
+ target_band (str): The band name to be used for the output anomaly image. e.g. 'ndvi'
2957
+ 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'.
2958
+ 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.
2959
+
2960
+ Returns:
2961
+ ee.Image: An image with the following bands: 's_statistic', 'variance', 'z_score', 'confidence', and 'slope'.
2962
+ """
2963
+ ########## PART 1 - S-VALUE CALCULATION ##########
2964
+ ##### https://vsp.pnnl.gov/help/vsample/design_trend_mann_kendall.htm #####
2965
+ image_collection = self
2966
+ if isinstance(image_collection, Sentinel2Collection):
2967
+ image_collection = image_collection.collection
2968
+ elif isinstance(image_collection, ee.ImageCollection):
2969
+ pass
2970
+ else:
2971
+ raise ValueError(f'The chosen `image_collection`: {image_collection} is not a valid Sentinel2Collection or ee.ImageCollection object.')
2972
+
2973
+ if target_band is None:
2974
+ raise ValueError('The `target_band` parameter must be specified.')
2975
+ if not isinstance(target_band, str):
2976
+ raise ValueError(f'The chosen `target_band`: {target_band} is not a valid string.')
2977
+
2978
+ if geometry is not None and not isinstance(geometry, ee.Geometry):
2979
+ raise ValueError(f'The chosen `geometry`: {geometry} is not a valid ee.Geometry object.')
2980
+ # define the join, which will join all images newer than the current image
2981
+ # use system:time_start if the image does not have a Date_Filter property
2982
+ if join_method == 'system:time_start':
2983
+ # get all images where the leftField value is less than (before) the rightField value
2984
+ time_filter = ee.Filter.lessThan(leftField='system:time_start',
2985
+ rightField='system:time_start')
2986
+ elif join_method == 'Date_Filter':
2987
+ # get all images where the leftField value is less than (before) the rightField value
2988
+ time_filter = ee.Filter.lessThan(leftField='Date_Filter',
2989
+ rightField='Date_Filter')
2990
+ else:
2991
+ raise ValueError(f'The chosen `join_method`: {join_method} does not match the options of "system:time_start" or "Date_Filter".')
2992
+
2993
+ # for any matches during a join, set image as a property key called 'future_image'
2994
+ join = ee.Join.saveAll(matchesKey='future_image')
2995
+
2996
+ # apply the join on the input collection
2997
+ # joining all images newer than the current image with the current image
2998
+ joined_collection = ee.ImageCollection(join.apply(primary=image_collection,
2999
+ secondary=image_collection, condition=time_filter))
3000
+
3001
+ # defining a collection to calculate the partial S value for each match in the join
3002
+ # e.g. t4-t1, t3-t1, t2-1 if there are 4 images
3003
+ def calculate_partial_s(current_image):
3004
+ # select the target band for arithmetic
3005
+ current_val = current_image.select(target_band)
3006
+ # get the joined images from the current image properties and cast the joined images as a list
3007
+ future_image_list = ee.List(current_image.get('future_image'))
3008
+ # convert the joined list to an image collection
3009
+ future_image_collection = ee.ImageCollection(future_image_list)
3010
+
3011
+ # define a function that will calculate the difference between the joined images and the current image,
3012
+ # then calculate the partial S sign based on the value of the difference calculation
3013
+ def get_sign(future_image):
3014
+ # select the target band for arithmetic from the future image
3015
+ future_val = future_image.select(target_band)
3016
+ # calculate the difference, i.e. t2-t1
3017
+ difference = future_val.subtract(current_val)
3018
+ # determine the sign of the difference value (1 if diff > 0, 0 if 0, and -1 if diff < 0)
3019
+ # use .unmask(0) to set any masked pixels as 0 to avoid
3020
+
3021
+ sign = difference.signum().unmask(0)
3022
+
3023
+ return sign
3024
+
3025
+ # map the get_sign() function along the future image col
3026
+ # then sum the values for each pixel to get the partial S value
3027
+ return future_image_collection.map(get_sign).sum()
3028
+
3029
+ # calculate the partial s value for each image in the joined/input image collection
3030
+ partial_s_col = joined_collection.map(calculate_partial_s)
3031
+
3032
+ # convert the image collection to an image of s_statistic values per pixel
3033
+ # where the s_statistic is the sum of partial s values
3034
+ # renaming the band as 's_statistic' for later usage
3035
+ final_s_image = partial_s_col.sum().rename('s_statistic')
3036
+
3037
+
3038
+ ########## PART 2 - VARIANCE and Z-SCORE ##########
3039
+ # to calculate variance we need to know how many pixels were involved in the partial_s calculations per pixel
3040
+ # we do this by using count() and turn the value to a float for later arithmetic
3041
+ n = image_collection.select(target_band).count().toFloat()
3042
+
3043
+ ##### VARIANCE CALCULATION #####
3044
+ # as we are using floating point values with high precision, it is HIGHLY
3045
+ # unlikely that there will be multiple pixel values with the same value.
3046
+ # Thus, we opt to use the simplified variance calculation approach as the
3047
+ # impacts to the output value are negligible and the processing benefits are HUGE
3048
+ # variance = (n * (n - 1) * (2n + 5)) / 18
3049
+ var_s = n.multiply(n.subtract(1))\
3050
+ .multiply(n.multiply(2).add(5))\
3051
+ .divide(18).rename('variance')
3052
+
3053
+ z_score = ee.Image().expression(
3054
+ """
3055
+ (s > 0) ? (s - 1) / sqrt(var) :
3056
+ (s < 0) ? (s + 1) / sqrt(var) :
3057
+ 0
3058
+ """,
3059
+ {'s': final_s_image, 'var': var_s}
3060
+ ).rename('z_score')
3061
+
3062
+ confidence = z_score.abs().divide(ee.Number(2).sqrt()).erf().rename('confidence')
3063
+
3064
+ stat_bands = ee.Image([var_s, z_score, confidence])
3065
+
3066
+ mk_stats_image = final_s_image.addBands(stat_bands)
3067
+
3068
+ ########## PART 3 - Sen's Slope ##########
3069
+ def add_year_band(image):
3070
+ if join_method == 'Date_Filter':
3071
+ # Get the string 'YYYY-MM-DD'
3072
+ date_string = image.get('Date_Filter')
3073
+ # Parse it into an ee.Date object (handles the conversion to time math)
3074
+ date = ee.Date.parse('YYYY-MM-dd', date_string)
3075
+ else:
3076
+ # Standard way: assumes system:time_start exists
3077
+ date = image.date()
3078
+ years = date.difference(ee.Date('1970-01-01'), 'year')
3079
+ return image.addBands(ee.Image(years).float().rename('year'))
3080
+
3081
+ slope_input = image_collection.map(add_year_band).select(['year', target_band])
3082
+
3083
+ sens_slope = slope_input.reduce(ee.Reducer.sensSlope())
3084
+
3085
+ slope_band = sens_slope.select('slope')
3086
+
3087
+ # add a mask to the final image to remove pixels with less than min_observations
3088
+ # mainly an effort to mask pixels outside of the boundary of the input image collection
3089
+ min_observations = 1
3090
+ valid_mask = n.gte(min_observations)
3091
+
3092
+ final_image = mk_stats_image.addBands(slope_band).updateMask(valid_mask)
3093
+
3094
+ if geometry is not None:
3095
+ mask = ee.Image(1).clip(geometry)
3096
+ final_image = final_image.updateMask(mask)
3097
+
3098
+ return final_image
3099
+
3100
+ def sens_slope_trend(self, target_band=None, join_method='system:time_start', geometry=None):
3101
+ """
3102
+ Calculates Sen's Slope (trend magnitude) for the collection.
3103
+ This is a lighter-weight alternative to the full `mann_kendall_trend` function if only
3104
+ the direction and magnitude of the trend are needed.
3105
+
3106
+ Be sure to select the correct band for the `target_band` parameter, as this will be used to calculate the trend statistics.
3107
+ You may optionally provide an ee.Geometry object for the `geometry` parameter to limit the area over which the trend statistics are calculated.
3108
+ 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.
3109
+
3110
+ Args:
3111
+ target_band (str): The name of the band to analyze. Defaults to 'ndvi'.
3112
+ join_method (str): Property to use for time sorting ('system:time_start' or 'Date_Filter').
3113
+ geometry (ee.Geometry, optional): Geometry to mask the final output.
3114
+
3115
+ Returns:
3116
+ ee.Image: An image containing the 'slope' band.
3117
+ """
3118
+ image_collection = self
3119
+ if isinstance(image_collection, Sentinel2Collection):
3120
+ image_collection = image_collection.collection
3121
+ elif isinstance(image_collection, ee.ImageCollection):
3122
+ pass
3123
+ else:
3124
+ raise ValueError(f'The chosen `image_collection`: {image_collection} is not a valid Sentinel2Collection or ee.ImageCollection object.')
3125
+
3126
+ if target_band is None:
3127
+ raise ValueError('The `target_band` parameter must be specified.')
3128
+ if not isinstance(target_band, str):
3129
+ raise ValueError(f'The chosen `target_band`: {target_band} is not a valid string.')
3130
+
3131
+ if geometry is not None and not isinstance(geometry, ee.Geometry):
3132
+ raise ValueError(f'The chosen `geometry`: {geometry} is not a valid ee.Geometry object.')
3133
+
3134
+ # Add Year Band (Time X-Axis)
3135
+ def add_year_band(image):
3136
+ # Handle user-defined date strings vs system time
3137
+ if join_method == 'Date_Filter':
3138
+ date_string = image.get('Date_Filter')
3139
+ date = ee.Date.parse('YYYY-MM-dd', date_string)
3140
+ else:
3141
+ date = image.date()
3142
+
3143
+ # Convert to fractional years relative to epoch
3144
+ years = date.difference(ee.Date('1970-01-01'), 'year')
3145
+ return image.addBands(ee.Image(years).float().rename('year'))
3146
+
3147
+ # Prepare Collection: Select ONLY [Year, Target]
3148
+ # sensSlope expects Band 0 = Independent (X), Band 1 = Dependent (Y)
3149
+ slope_input = self.collection.map(add_year_band).select(['year', target_band])
3150
+
3151
+ # Run the Native Reducer
3152
+ sens_result = slope_input.reduce(ee.Reducer.sensSlope())
3153
+
3154
+ # Extract and Mask
3155
+ slope_band = sens_result.select('slope')
3156
+
3157
+ if geometry is not None:
3158
+ mask = ee.Image(1).clip(geometry)
3159
+ slope_band = slope_band.updateMask(mask)
3160
+
3161
+ return slope_band
3162
+
3163
+
2522
3164
  def mask_via_band(self, band_to_mask, band_for_mask, threshold=-1, mask_above=True, add_band_to_original_image=False):
2523
3165
  """
2524
3166
  Masks select pixels of a selected band from an image based on another specified band and threshold (optional).
@@ -2601,7 +3243,8 @@ class Sentinel2Collection:
2601
3243
  )
2602
3244
 
2603
3245
  # guarantee single band + keep properties
2604
- out = ee.Image(out).select([band_name_to_mask]).copyProperties(prim, prim.propertyNames())
3246
+ out = ee.Image(out).select([band_name_to_mask]).copyProperties(prim, prim.propertyNames())\
3247
+ .set('system:time_start', prim.get('system:time_start'))
2605
3248
  out = out.set('Date_Filter', prim.get('Date_Filter'))
2606
3249
  return ee.Image(out) # <-- return as Image
2607
3250