RadGEEToolbox 1.6.7__py3-none-any.whl → 1.6.9__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.
@@ -141,6 +141,12 @@ class LandsatCollection:
141
141
  self.gypsum_threshold = -1
142
142
  self.turbidity_threshold = -1
143
143
  self.chlorophyll_threshold = -0.5
144
+ self.ndsi_threshold = -1
145
+ self.evi_threshold = -1
146
+ self.savi_threshold = -1.5
147
+ self.msavi_threshold = -1
148
+ self.ndmi_threshold = -1
149
+ self.nbr_threshold = -1
144
150
  self._masked_clouds_collection = None
145
151
  self._masked_water_collection = None
146
152
  self._masked_to_water_collection = None
@@ -150,9 +156,16 @@ class LandsatCollection:
150
156
  self._mean = None
151
157
  self._max = None
152
158
  self._min = None
159
+ self._albedo = None
153
160
  self._ndwi = None
154
161
  self._mndwi = None
155
162
  self._ndvi = None
163
+ self._ndsi = None
164
+ self._evi = None
165
+ self._savi = None
166
+ self._msavi = None
167
+ self._ndmi = None
168
+ self._nbr = None
156
169
  self._halite = None
157
170
  self._gypsum = None
158
171
  self._turbidity = None
@@ -466,6 +479,229 @@ class LandsatCollection:
466
479
  )
467
480
  return chlorophyll
468
481
 
482
+ @staticmethod
483
+ def landsat_broadband_albedo_fn(image):
484
+ """
485
+ Calculates broadband albedo for Landsat TM and OLI images, based on Liang, 2001
486
+ (https://doi.org/10.1016/S0034-4257(00)00205-4) and Wang et al., 2016 (https://doi.org/10.1016/j.rse.2016.02.059).
487
+
488
+ Args:
489
+ image (ee.Image): input ee.Image
490
+
491
+ Returns:
492
+ ee.Image: broadband albedo ee.Image
493
+ """
494
+ # Conversion using Liang, 2001 as reference
495
+ TM_expression = '0.356*b("SR_B2") + 0.130*b("SR_B4") + 0.373*b("SR_B5") + 0.085*b("SR_B6") + 0.072*b("SR_B8") - 0.0018'
496
+ # Conversion using Wang et al., 2016 as reference
497
+ OLI_expression = '0.2453*b("SR_B2") + 0.0508*b("SR_B3") + 0.1804*b("SR_B4") + 0.3081*b("SR_B5") + 0.1332*b("SR_B6") + 0.0521*b("SR_B7") + 0.0011'
498
+ # If spacecraft is Landsat 5 TM, use the correct expression,
499
+ # otherwise treat as OLI and copy properties after renaming band to "albedo"
500
+ albedo = ee.Algorithms.If(
501
+ ee.String(image.get("SPACECRAFT_ID")).equals("LANDSAT_5"),
502
+ image.expression(TM_expression).rename("albedo").copyProperties(image),
503
+ image.expression(OLI_expression).rename("albedo").copyProperties(image))
504
+ return albedo
505
+
506
+ @staticmethod
507
+ def landsat_ndsi_fn(image, threshold, ng_threshold=None):
508
+ """
509
+ Calculates the Normalized Difference Snow Index (NDSI) for Landsat images. Masks image based on threshold. Can specify separate thresholds
510
+ for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5 and the ng_threshold argument applies to Landsat 8&9
511
+
512
+ Args:
513
+ image (ee.Image): input ee.Image
514
+ threshold (float): value between -1 and 1 where NDSI pixels greater than threshold will be masked, applies to landsat 5 when ng_threshold is also set.
515
+ ng_threshold (float, optional): integer threshold to be applied to landsat 8 or 9 where NDSI pixels greater than threshold are masked.
516
+
517
+ Returns:
518
+ ee.Image: NDSI ee.Image
519
+ """
520
+ ndsi_calc = image.normalizedDifference(["SR_B3", "SR_B6"])
521
+ if ng_threshold != None:
522
+ ndsi = ee.Algorithms.If(
523
+ ee.String(image.get("SPACECRAFT_ID")).equals("LANDSAT_5"),
524
+ ndsi_calc.updateMask(ndsi_calc.gte(threshold))
525
+ .rename("ndsi")
526
+ .copyProperties(image)
527
+ .set("threshold", threshold),
528
+ ndsi_calc.updateMask(ndsi_calc.gte(ng_threshold))
529
+ .rename("ndsi")
530
+ .copyProperties(image)
531
+ .set("threshold", ng_threshold),
532
+ )
533
+ else:
534
+ ndsi = ndsi_calc.updateMask(ndsi_calc.gte(threshold)).rename("ndsi").copyProperties(image).set("threshold", threshold)
535
+ return ndsi
536
+
537
+ @staticmethod
538
+ def landsat_evi_fn(image, threshold, ng_threshold=None, gain_factor=2.5, l=1, c1=6, c2=7.5):
539
+ """
540
+ Calculates the Enhanced Vegetation Index (EVI) for Landsat images. Masks image based on threshold. Can specify separate thresholds
541
+ for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5 and the ng_threshold argument applies to Landsat 8&9.
542
+ See https://www.usgs.gov/landsat-missions/landsat-enhanced-vegetation-index
543
+
544
+ Args:
545
+ image (ee.Image): input ee.Image
546
+ threshold (float): value between -1 and 1 where EVI pixels less than threshold will be masked, applies to landsat 5 when ng_threshold is also set.
547
+ ng_threshold (float, optional): integer threshold to be applied to landsat 8 or 9 where EVI pixels less than threshold are masked.
548
+ gain_factor (float, optional): Gain factor, typically set to 2.5. Defaults to 2.5.
549
+ l (float, optional): Canopy background adjustment factor, typically set to 1. Defaults to 1.
550
+ c1 (float, optional): Coefficient for the aerosol resistance term, typically set to 6. Defaults to 6.
551
+ c2 (float, optional): Coefficient for the aerosol resistance term, typically set to 7.5. Defaults to 7.5.
552
+
553
+ Returns:
554
+ ee.Image: EVI ee.Image
555
+ """
556
+ evi_expression = f'{gain_factor} * ((b("SR_B5") - b("SR_B4")) / (b("SR_B5") + {c1} * b("SR_B4") - {c2} * b("SR_B2") + {l}))'
557
+ evi_calc = image.expression(evi_expression)
558
+ if ng_threshold != None:
559
+ evi = ee.Algorithms.If(
560
+ ee.String(image.get("SPACECRAFT_ID")).equals("LANDSAT_5"),
561
+ evi_calc.updateMask(evi_calc.gte(threshold))
562
+ .rename("evi")
563
+ .copyProperties(image)
564
+ .set("threshold", threshold),
565
+ evi_calc.updateMask(evi_calc.gte(ng_threshold))
566
+ .rename("evi")
567
+ .copyProperties(image)
568
+ .set("threshold", ng_threshold),
569
+ )
570
+ else:
571
+ evi = evi_calc.updateMask(evi_calc.gte(threshold)).rename("evi").copyProperties(image).set("threshold", threshold)
572
+ return evi
573
+
574
+ @staticmethod
575
+ def landsat_savi_fn(image, threshold, ng_threshold=None, l=0.5):
576
+ """
577
+ Calculates the Soil-Adjusted Vegetation Index (SAVI) for Landsat images. Masks image based on threshold. Can specify separate thresholds
578
+ for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5 and the ng_threshold argument applies to Landsat 8&9.
579
+ See Huete, 1988 - https://doi.org/10.1016/0034-4257(88)90106-X
580
+
581
+ Args:
582
+ image (ee.Image): input ee.Image
583
+ threshold (float): value between -1 and 1 where SAVI pixels less than threshold will be masked, applies to landsat 5 when ng_threshold is also set.
584
+ ng_threshold (float, optional): integer threshold to be applied to landsat 8 or 9 where SAVI pixels less than threshold are masked.
585
+ l (float, optional): Soil brightness correction factor, typically set to 0.5. Defaults to 0.5.
586
+ Returns:
587
+ ee.Image: SAVI ee.Image
588
+ """
589
+ savi_expression = f'((b("SR_B5") - b("SR_B4")) / (b("SR_B5") + b("SR_B4") + {l})) * (1 + {l})'
590
+ savi_calc = image.expression(savi_expression)
591
+ if ng_threshold != None:
592
+ savi = ee.Algorithms.If(
593
+ ee.String(image.get("SPACECRAFT_ID")).equals("LANDSAT_5"),
594
+ savi_calc.updateMask(savi_calc.gte(threshold))
595
+ .rename("savi")
596
+ .copyProperties(image)
597
+ .set("threshold", threshold),
598
+ savi_calc.updateMask(savi_calc.gte(ng_threshold))
599
+ .rename("savi")
600
+ .copyProperties(image)
601
+ .set("threshold", ng_threshold),
602
+ )
603
+ else:
604
+ savi = savi_calc.updateMask(savi_calc.gte(threshold)).rename("savi").copyProperties(image).set("threshold", threshold)
605
+ return savi
606
+
607
+ @staticmethod
608
+ def landsat_msavi_fn(image, threshold, ng_threshold=None):
609
+ """
610
+ Calculates the Modified Soil-Adjusted Vegetation Index (MSAVI) for Landsat images. Masks image based on threshold. Can specify separate thresholds
611
+ for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5 and the ng_threshold argument applies to Landsat 8&9.
612
+ See Qi et al., 1994 - https://doi.org/10.1016/0034-4257(94)90134-1 and https://www.usgs.gov/landsat-missions/landsat-modified-soil-adjusted-vegetation-index
613
+
614
+ Args:
615
+ image (ee.Image): input ee.Image
616
+ threshold (float): value between -1 and 1 where MSAVI pixels less than threshold will be masked, applies to landsat 5 when ng_threshold is also set.
617
+ ng_threshold (float, optional): integer threshold to be applied to landsat 8 or 9 where MSAVI pixels less than threshold are masked.
618
+
619
+ Returns:
620
+ ee.Image: MSAVI ee.Image
621
+ """
622
+ msavi_expression = '0.5 * (2 * b("SR_B5") + 1 - ((2 * b("SR_B5") + 1) ** 2 - 8 * (b("SR_B5") - b("SR_B4"))) ** 0.5)'
623
+ msavi_calc = image.expression(msavi_expression)
624
+ if ng_threshold != None:
625
+ msavi = ee.Algorithms.If(
626
+ ee.String(image.get("SPACECRAFT_ID")).equals("LANDSAT_5"),
627
+ msavi_calc.updateMask(msavi_calc.gte(threshold))
628
+ .rename("msavi")
629
+ .copyProperties(image)
630
+ .set("threshold", threshold),
631
+ msavi_calc.updateMask(msavi_calc.gte(ng_threshold))
632
+ .rename("msavi")
633
+ .copyProperties(image)
634
+ .set("threshold", ng_threshold),
635
+ )
636
+ else:
637
+ msavi = msavi_calc.updateMask(msavi_calc.gte(threshold)).rename("msavi").copyProperties(image).set("threshold", threshold)
638
+ return msavi
639
+
640
+ @staticmethod
641
+ def landsat_ndmi_fn(image, threshold, ng_threshold=None):
642
+ """
643
+ Calculates the Normalized Difference Moisture Index (NDMI) for Landsat images. Masks image based on threshold. Can specify separate thresholds
644
+ for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5 and the ng_threshold argument applies to Landsat 8&9.
645
+ See Wilson & Sader, 2002 - https://doi.org/10.1016/S0034-4257(02)00074-7
646
+
647
+ Args:
648
+ image (ee.Image): input ee.Image
649
+ threshold (float): value between -1 and 1 where NDMI pixels less than threshold will be masked, applies to landsat 5 when ng_threshold is also set.
650
+ ng_threshold (float, optional): integer threshold to be applied to landsat 8 or 9 where NDMI pixels less than threshold are masked.
651
+
652
+ Returns:
653
+ ee.Image: NDMI ee.Image
654
+ """
655
+ ndmi_expression = '(b("SR_B5") - b("SR_B6")) / (b("SR_B5") + b("SR_B6"))'
656
+ ndmi_calc = image.expression(ndmi_expression)
657
+ if ng_threshold != None:
658
+ ndmi = ee.Algorithms.If(
659
+ ee.String(image.get("SPACECRAFT_ID")).equals("LANDSAT_5"),
660
+ ndmi_calc.updateMask(ndmi_calc.gte(threshold))
661
+ .rename("ndmi")
662
+ .copyProperties(image)
663
+ .set("threshold", threshold),
664
+ ndmi_calc.updateMask(ndmi_calc.gte(ng_threshold))
665
+ .rename("ndmi")
666
+ .copyProperties(image)
667
+ .set("threshold", ng_threshold),
668
+ )
669
+ else:
670
+ ndmi = ndmi_calc.updateMask(ndmi_calc.gte(threshold)).rename("ndmi").copyProperties(image).set("threshold", threshold)
671
+ return ndmi
672
+
673
+ @staticmethod
674
+ def landsat_nbr_fn(image, threshold, ng_threshold=None):
675
+ """
676
+ Calculates the Normalized Burn Ratio (NBR) for Landsat images. Masks image based on threshold. Can specify separate thresholds
677
+ for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5 and the ng_threshold argument applies to Landsat 8&9.
678
+
679
+ Args:
680
+ image (ee.Image): input ee.Image
681
+ threshold (float): value between -1 and 1 where NBR pixels less than threshold will be masked, applies to landsat 5 when ng_threshold is also set.
682
+ ng_threshold (float, optional): integer threshold to be applied to landsat 8 or 9 where NBR pixels less than threshold are masked.
683
+
684
+ Returns:
685
+ ee.Image: NBR ee.Image
686
+ """
687
+ nbr_expression = '(b("SR_B5") - b("SR_B7")) / (b("SR_B5") + b("SR_B7"))'
688
+ nbr_calc = image.expression(nbr_expression)
689
+ if ng_threshold != None:
690
+ nbr = ee.Algorithms.If(
691
+ ee.String(image.get("SPACECRAFT_ID")).equals("LANDSAT_5"),
692
+ nbr_calc.updateMask(nbr_calc.gte(threshold))
693
+ .rename("nbr")
694
+ .copyProperties(image)
695
+ .set("threshold", threshold),
696
+ nbr_calc.updateMask(nbr_calc.gte(ng_threshold))
697
+ .rename("nbr")
698
+ .copyProperties(image)
699
+ .set("threshold", ng_threshold),
700
+ )
701
+ else:
702
+ nbr = nbr_calc.updateMask(nbr_calc.gte(threshold)).rename("nbr").copyProperties(image).set("threshold", threshold)
703
+ return nbr
704
+
469
705
  @staticmethod
470
706
  def MaskWaterLandsat(image):
471
707
  """
@@ -729,57 +965,87 @@ class LandsatCollection:
729
965
  ):
730
966
  """
731
967
  Calculates the summation of area for pixels of interest (above a specific threshold) in a geometry
732
- and store the value as image property (matching name of chosen band).
968
+ and store the value as image property (matching name of chosen band). If multiple band names are provided in a list,
969
+ the function will calculate area for each band in the list and store each as a separate property.
970
+
971
+ NOTE: The resulting value has units of square meters.
733
972
 
734
973
  Args:
735
974
  image (ee.Image): input ee.Image
736
- band_name (string): name of band (string) for calculating area
975
+ band_name (string or list of strings): name of band(s) (string) for calculating area. If providing multiple band names, pass as a list of strings.
737
976
  geometry (ee.Geometry): ee.Geometry object denoting area to clip to for area calculation
738
- threshold (float): integer threshold to specify masking of pixels below threshold (defaults to -1)
977
+ threshold (float): integer threshold to specify masking of pixels below threshold (defaults to -1). If providing multiple band names, the same threshold will be applied to all bands. Best practice in this case is to mask the bands prior to passing to this function and leave threshold at default of -1.
739
978
  scale (int): integer scale of image resolution (meters) (defaults to 30)
740
979
  maxPixels (int): integer denoting maximum number of pixels for calculations
741
980
 
742
981
  Returns:
743
- ee.Image: ee.Image with area calculation stored as property matching name of band
982
+ ee.Image: ee.Image with area calculation in square meters stored as property matching name of band
744
983
  """
984
+ # Ensure band_name is a server-side ee.List for consistent processing. Wrap band_name in a list if it's a single string.
985
+ bands = ee.List(band_name) if isinstance(band_name, list) else ee.List([band_name])
986
+ # Create an image representing the area of each pixel in square meters
745
987
  area_image = ee.Image.pixelArea()
746
- mask = image.select(band_name).gte(threshold)
747
- final = image.addBands(area_image)
748
- stats = (
749
- final.select("area")
750
- .updateMask(mask)
751
- .rename(band_name)
752
- .reduceRegion(
753
- reducer=ee.Reducer.sum(),
754
- geometry=geometry,
755
- scale=scale,
756
- maxPixels=maxPixels,
988
+
989
+ # Function to iterate over each band and calculate area, storing the result as a property on the image
990
+ def calculate_and_set_area(band, img_accumulator):
991
+ # Explcitly cast inputs to expected types
992
+ img_accumulator = ee.Image(img_accumulator)
993
+ band = ee.String(band)
994
+
995
+ # Create a mask from the input image for the current band
996
+ mask = img_accumulator.select(band).gte(threshold)
997
+ # Combine the original image with the area image
998
+ final = img_accumulator.addBands(area_image)
999
+
1000
+ # Calculation of area for a given band, utilizing other inputs
1001
+ stats = (
1002
+ final.select("area").updateMask(mask)
1003
+ .rename(band) # renames 'area' to band name like 'ndwi'
1004
+ .reduceRegion(
1005
+ reducer=ee.Reducer.sum(),
1006
+ geometry=geometry,
1007
+ scale=scale,
1008
+ maxPixels=maxPixels,
1009
+ )
757
1010
  )
758
- )
759
- return image.set(band_name, stats.get(band_name))
1011
+ # Retrieving the area value from the stats dictionary with stats.get(band), as the band name is now the key
1012
+ reduced_area = stats.get(band)
1013
+ # Checking whether the calculated area is valid and replaces with 0 if not. This avoids breaking the loop for erroneous images.
1014
+ area_value = ee.Algorithms.If(reduced_area, reduced_area, 0)
1015
+
1016
+ # Set the property on the image, named after the band
1017
+ return img_accumulator.set(band, area_value)
1018
+
1019
+ # Call to iterate the calculate_and_set_area function over the list of bands, starting with the original image
1020
+ final_image = ee.Image(bands.iterate(calculate_and_set_area, image))
1021
+ return final_image
760
1022
 
761
1023
  def PixelAreaSumCollection(
762
- self, band_name, geometry, threshold=-1, scale=30, maxPixels=1e12
1024
+ self, band_name, geometry, threshold=-1, scale=30, maxPixels=1e12, output_type='ImageCollection', area_data_export_path=None
763
1025
  ):
764
1026
  """
765
- Calculates the summation of area for pixels of interest (above a specific threshold)
766
- within a geometry and store the value as image property (matching name of chosen band) for an entire
767
- image collection.
1027
+ Calculates the geodesic summation of area for pixels of interest (above a specific threshold)
1028
+ within a geometry and stores the value as an image property (matching name of chosen band) for an entire
1029
+ image collection. Optionally exports the area data to a CSV file.
768
1030
 
769
- The resulting value has units of square meters.
1031
+ NOTE: The resulting value has units of square meters.
770
1032
 
771
1033
  Args:
772
- band_name (string): name of band (string) for calculating area
1034
+ band_name (string or list of strings): name of band(s) (string) for calculating area. If providing multiple band names, pass as a list of strings.
773
1035
  geometry (ee.Geometry): ee.Geometry object denoting area to clip to for area calculation
774
- threshold (float): integer threshold to specify masking of pixels below threshold (defaults to -1)
1036
+ threshold (float): integer threshold to specify masking of pixels below threshold (defaults to -1). If providing multiple band names, the same threshold will be applied to all bands. Best practice in this case is to mask the bands prior to passing to this function and leave threshold at default of -1.
775
1037
  scale (int): integer scale of image resolution (meters) (defaults to 30)
776
1038
  maxPixels (int): integer denoting maximum number of pixels for calculations
1039
+ output_type (str): 'ImageCollection' to return an ee.ImageCollection, 'LandsatCollection' to return a LandsatCollection object (defaults to 'ImageCollection')
1040
+ area_data_export_path (str, optional): If provided, the function will save the resulting area data to a CSV file at the specified path.
777
1041
 
778
1042
  Returns:
779
- ee.ImageCollection: Image with area calculation stored as property matching name of band.
1043
+ ee.ImageCollection or LandsatCollection: Image collection of images with area calculation (square meters) stored as property matching name of band. Type of output depends on output_type argument.
780
1044
  """
1045
+ # If the area calculation has not been computed for this LandsatCollection instance, the area will be calculated for the provided bands
781
1046
  if self._PixelAreaSumCollection is None:
782
1047
  collection = self.collection
1048
+ # Area calculation for each image in the collection, using the PixelAreaSum function
783
1049
  AreaCollection = collection.map(
784
1050
  lambda image: LandsatCollection.PixelAreaSum(
785
1051
  image,
@@ -790,8 +1056,38 @@ class LandsatCollection:
790
1056
  maxPixels=maxPixels,
791
1057
  )
792
1058
  )
1059
+ # Storing the result in the instance variable to avoid redundant calculations
793
1060
  self._PixelAreaSumCollection = AreaCollection
794
- return self._PixelAreaSumCollection
1061
+
1062
+ # If an export path is provided, the area data will be exported to a CSV file
1063
+ if area_data_export_path:
1064
+ LandsatCollection(collection=self._PixelAreaSumCollection).ExportProperties(property_names=band_name, file_path=area_data_export_path+'.csv')
1065
+
1066
+ # Returning the result in the desired format based on output_type argument or raising an error for invalid input
1067
+ if output_type == 'ImageCollection':
1068
+ return self._PixelAreaSumCollection
1069
+ elif output_type == 'LandsatCollection':
1070
+ return LandsatCollection(collection=self._PixelAreaSumCollection)
1071
+ else:
1072
+ raise ValueError("output_type must be 'ImageCollection' or 'LandsatCollection'")
1073
+
1074
+ def merge(self, other):
1075
+ """
1076
+ Merges the current LandsatCollection with another LandsatCollection, where images/bands with the same date are combined to a single multiband image.
1077
+
1078
+ Args:
1079
+ other (LandsatCollection): Another LandsatCollection to merge with current collection.
1080
+
1081
+ Returns:
1082
+ LandsatCollection: A new LandsatCollection containing images from both collections.
1083
+ """
1084
+ # Checking if 'other' is an instance of LandsatCollection
1085
+ if not isinstance(other, LandsatCollection):
1086
+ raise ValueError("The 'other' parameter must be an instance of LandsatCollection.")
1087
+
1088
+ # Merging the collections using the .combine() method
1089
+ merged_collection = self.collection.combine(other.collection)
1090
+ return LandsatCollection(collection=merged_collection)
795
1091
 
796
1092
  @staticmethod
797
1093
  def dNDWIPixelAreaSum(image, geometry, band_name="ndwi", scale=30, maxPixels=1e12):
@@ -891,6 +1187,60 @@ class LandsatCollection:
891
1187
  self._dates = dates
892
1188
  return self._dates
893
1189
 
1190
+ def ExportProperties(self, property_names, file_path=None):
1191
+ """
1192
+ Fetches and returns specified properties from each image in the collection as a list, and returns a pandas DataFrame and optionally saves the results to a csv file.
1193
+
1194
+ Args:
1195
+ property_names (list or str): A property name or list of property names to retrieve. The 'Date_Filter' property is always included to provide temporal context.
1196
+ file_path (str, optional): If provided, the function will save the resulting DataFrame to a CSV file at this path. Defaults to None.
1197
+
1198
+ Returns:
1199
+ pd.DataFrame: A pandas DataFrame containing the requested properties for each image, sorted chronologically by 'Date_Filter'.
1200
+ """
1201
+ # Ensure property_names is a list for consistent processing
1202
+ if isinstance(property_names, str):
1203
+ property_names = [property_names]
1204
+
1205
+ # Ensure properties are included without duplication, including 'Date_Filter'
1206
+ all_properties_to_fetch = list(set(['Date_Filter'] + property_names))
1207
+
1208
+ # Defining the helper function to create features with specified properties
1209
+ def create_feature_with_properties(image):
1210
+ """A function to map over the collection and store the image properties as an ee.Feature.
1211
+ Args:
1212
+ image (ee.Image): An image from the collection.
1213
+ Returns:
1214
+ ee.Feature: A feature containing the specified properties from the image.
1215
+ """
1216
+ properties = image.toDictionary(all_properties_to_fetch)
1217
+ return ee.Feature(None, properties)
1218
+
1219
+ # Map the feature creation function over the server-side collection.
1220
+ # The result is an ee.FeatureCollection where each feature holds the properties of one image.
1221
+ mapped_collection = self.collection.map(create_feature_with_properties)
1222
+ # Explicitly cast to ee.FeatureCollection for clarity
1223
+ feature_collection = ee.FeatureCollection(mapped_collection)
1224
+
1225
+ # Use the existing ee_to_df static method. This performs the single .getInfo() call
1226
+ # and converts the structured result directly to a pandas DataFrame.
1227
+ df = LandsatCollection.ee_to_df(feature_collection, columns=all_properties_to_fetch)
1228
+
1229
+ # Sort by date for a clean, chronological output.
1230
+ if 'Date_Filter' in df.columns:
1231
+ df = df.sort_values(by='Date_Filter').reset_index(drop=True)
1232
+
1233
+ # Check condition for saving to CSV
1234
+ if file_path:
1235
+ # Check whether file_path ends with .csv, if not, append it
1236
+ if not file_path.lower().endswith('.csv'):
1237
+ file_path += '.csv'
1238
+ # Save DataFrame to CSV
1239
+ df.to_csv(file_path, index=True)
1240
+ print(f"Properties saved to {file_path}")
1241
+
1242
+ return df
1243
+
894
1244
  def get_filtered_collection(self):
895
1245
  """
896
1246
  Filters image collection based on LandsatCollection class arguments. Automatically calculated when using collection method, depending on provided class arguments (when tile info is provided).
@@ -1133,6 +1483,253 @@ class LandsatCollection:
1133
1483
  )
1134
1484
  )
1135
1485
  return LandsatCollection(collection=col)
1486
+
1487
+ @property
1488
+ def evi(self):
1489
+ """
1490
+ Property attribute to calculate and access the EVI (Enhanced Vegetation Index) imagery of the LandsatCollection.
1491
+ This property initiates the calculation of EVI and caches the result. The calculation is performed only once when
1492
+ the property is first accessed, and the cached result is returned on subsequent accesses.
1493
+
1494
+ Returns:
1495
+ LandsatCollection: A LandsatCollection image collection
1496
+ """
1497
+ if self._evi is None:
1498
+ self._evi = self.evi_collection(self.evi_threshold)
1499
+ return self._evi
1500
+
1501
+ def evi_collection(self, threshold, ng_threshold=None):
1502
+ """
1503
+ Function to calculate the EVI (Enhanced Vegetation Index) and return collection as class object, allows specifying threshold(s) for masking.
1504
+ Thresholds can be specified for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5
1505
+ and the ng_threshold argument applies to Landsat 8&9. This function can be called as a method but is called
1506
+ by default when using the evi property attribute.
1507
+
1508
+ Args:
1509
+ threshold (float): specify threshold for EVI function (values less than threshold are masked)
1510
+
1511
+ Returns:
1512
+ LandsatCollection: A LandsatCollection image collection
1513
+ """
1514
+ first_image = self.collection.first()
1515
+ available_bands = first_image.bandNames()
1516
+ if available_bands.contains("SR_B4") and available_bands.contains("SR_B5") and available_bands.contains("SR_B2"):
1517
+ pass
1518
+ else:
1519
+ raise ValueError("Insufficient Bands for evi calculation")
1520
+ col = self.collection.map(
1521
+ lambda image: LandsatCollection.landsat_evi_fn(
1522
+ image, threshold=threshold, ng_threshold=ng_threshold
1523
+ )
1524
+ )
1525
+ return LandsatCollection(collection=col)
1526
+
1527
+ @property
1528
+ def savi(self):
1529
+ """
1530
+ Property attribute to calculate and access the SAVI (Soil Adjusted Vegetation Index) imagery of the LandsatCollection.
1531
+ This property initiates the calculation of SAVI and caches the result. The calculation is performed only once when the
1532
+ property is first accessed, and the cached result is returned on subsequent accesses.
1533
+
1534
+ Returns:
1535
+ LandsatCollection: A LandsatCollection image collection
1536
+ """
1537
+ if self._savi is None:
1538
+ self._savi = self.savi_collection(self.savi_threshold)
1539
+ return self._savi
1540
+
1541
+ def savi_collection(self, threshold, ng_threshold=None, l=0.5):
1542
+ """
1543
+ Function to calculate the SAVI (Soil Adjusted Vegetation Index) and return collection as class object, allows specifying threshold(s) for masking.
1544
+ Thresholds can be specified for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5
1545
+ and the ng_threshold argument applies to Landsat 8&9. This function can be called as a method but is called
1546
+ by default when using the savi property attribute.
1547
+
1548
+ Args:
1549
+ threshold (float): specify threshold for SAVI function (values less than threshold are masked)
1550
+ ng_threshold (float, optional): specify threshold for Landsat 8&9 SAVI function (values less than threshold are masked)
1551
+ l (float, optional): Soil brightness correction factor, typically set to 0.5 for intermediate vegetation cover. Defaults to 0.5.
1552
+
1553
+ Returns:
1554
+ LandsatCollection: A LandsatCollection image collection
1555
+ """
1556
+ first_image = self.collection.first()
1557
+ available_bands = first_image.bandNames()
1558
+ if available_bands.contains("SR_B4") and available_bands.contains("SR_B5"):
1559
+ pass
1560
+ else:
1561
+ raise ValueError("Insufficient Bands for savi calculation")
1562
+ col = self.collection.map(
1563
+ lambda image: LandsatCollection.landsat_savi_fn(
1564
+ image, threshold=threshold, ng_threshold=ng_threshold, l=l
1565
+ )
1566
+ )
1567
+ return LandsatCollection(collection=col)
1568
+
1569
+ @property
1570
+ def msavi(self):
1571
+ """
1572
+ Property attribute to calculate and access the MSAVI (Modified Soil Adjusted Vegetation Index) imagery of the LandsatCollection.
1573
+ This property initiates the calculation of MSAVI and caches the result. The calculation is performed only once when the property
1574
+ is first accessed, and the cached result is returned on subsequent accesses.
1575
+
1576
+ Returns:
1577
+ LandsatCollection: A LandsatCollection image collection
1578
+ """
1579
+ if self._msavi is None:
1580
+ self._msavi = self.msavi_collection(self.msavi_threshold)
1581
+ return self._msavi
1582
+
1583
+ def msavi_collection(self, threshold, ng_threshold=None):
1584
+ """
1585
+ Function to calculate the MSAVI (Modified Soil Adjusted Vegetation Index) and return collection as class object, allows specifying threshold(s) for masking.
1586
+ Thresholds can be specified for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5
1587
+ and the ng_threshold argument applies to Landsat 8&9. This function can be called as a method but is called
1588
+ by default when using the msavi property attribute.
1589
+
1590
+ Args:
1591
+ threshold (float): specify threshold for MSAVI function (values less than threshold are masked)
1592
+ ng_threshold (float, optional): specify threshold for Landsat 8&9 MSAVI function (values less than threshold are masked)
1593
+
1594
+ Returns:
1595
+ LandsatCollection: A LandsatCollection image collection
1596
+ """
1597
+ first_image = self.collection.first()
1598
+ available_bands = first_image.bandNames()
1599
+ if available_bands.contains("SR_B4") and available_bands.contains("SR_B5"):
1600
+ pass
1601
+ else:
1602
+ raise ValueError("Insufficient Bands for msavi calculation")
1603
+ col = self.collection.map(
1604
+ lambda image: LandsatCollection.landsat_msavi_fn(
1605
+ image, threshold=threshold, ng_threshold=ng_threshold
1606
+ )
1607
+ )
1608
+ return LandsatCollection(collection=col)
1609
+
1610
+ @property
1611
+ def ndmi(self):
1612
+ """
1613
+ Property attribute to calculate and access the NDMI (Normalized Difference Moisture Index) imagery of the LandsatCollection.
1614
+ This property initiates the calculation of NDMI and caches the result. The calculation is performed only once when the property
1615
+ is first accessed, and the cached result is returned on subsequent accesses.
1616
+
1617
+ Returns:
1618
+ LandsatCollection: A LandsatCollection image collection
1619
+ """
1620
+ if self._ndmi is None:
1621
+ self._ndmi = self.ndmi_collection(self.ndmi_threshold)
1622
+ return self._ndmi
1623
+
1624
+ def ndmi_collection(self, threshold, ng_threshold=None):
1625
+ """
1626
+ Function to calculate the NDMI (Normalized Difference Moisture Index) and return collection as class object, allows specifying threshold(s) for masking.
1627
+ Thresholds can be specified for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5
1628
+ and the ng_threshold argument applies to Landsat 8&9 (when `ng_threshold` is specified, otherwise `threshold` applies to all imagery). This function can be called as a method but is called
1629
+ by default when using the ndmi property attribute.
1630
+
1631
+ Args:
1632
+ threshold (float): specify threshold for NDMI function (values less than threshold are masked)
1633
+ ng_threshold (float, optional): specify threshold for Landsat 8&9 NDMI function (values less than threshold are masked)
1634
+
1635
+ Returns:
1636
+ LandsatCollection: A LandsatCollection image collection
1637
+ """
1638
+ first_image = self.collection.first()
1639
+ available_bands = first_image.bandNames()
1640
+ if available_bands.contains("SR_B5") and available_bands.contains("SR_B6"):
1641
+ pass
1642
+ else:
1643
+ raise ValueError("Insufficient Bands for ndmi calculation")
1644
+ col = self.collection.map(
1645
+ lambda image: LandsatCollection.landsat_ndmi_fn(
1646
+ image, threshold=threshold, ng_threshold=ng_threshold
1647
+ )
1648
+ )
1649
+ return LandsatCollection(collection=col)
1650
+
1651
+ @property
1652
+ def nbr(self):
1653
+ """
1654
+ Property attribute to calculate and access the NBR (Normalized Burn Ratio) imagery of the LandsatCollection.
1655
+ This property initiates the calculation of NBR using a default threshold of -1 (or a previously set threshold of self.nbr_threshold)
1656
+ and caches the result. The calculation is performed only once when the property is first accessed, and the cached result is returned
1657
+ on subsequent accesses.
1658
+
1659
+ Returns:
1660
+ LandsatCollection: A LandsatCollection image collection
1661
+ """
1662
+ if self._nbr is None:
1663
+ self._nbr = self.nbr_collection(self.nbr_threshold)
1664
+ return self._nbr
1665
+
1666
+ def nbr_collection(self, threshold, ng_threshold=None):
1667
+ """
1668
+ Function to calculate the NBR (Normalized Burn Ratio) and return collection as class object, allows specifying threshold(s) for masking.
1669
+ Thresholds can be specified for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5
1670
+ and the ng_threshold argument applies to Landsat 8&9. This function can be called as a method but is called
1671
+ by default when using the nbr property attribute.
1672
+
1673
+ Args:
1674
+ threshold (float): specify threshold for NBR function (values less than threshold are masked)
1675
+ ng_threshold (float, optional): specify threshold for Landsat 8&9 NBR function (values less than threshold are masked)
1676
+
1677
+ Returns:
1678
+ LandsatCollection: A LandsatCollection image collection
1679
+ """
1680
+ first_image = self.collection.first()
1681
+ available_bands = first_image.bandNames()
1682
+ if available_bands.contains("SR_B5") and available_bands.contains("SR_B7"):
1683
+ pass
1684
+ else:
1685
+ raise ValueError("Insufficient Bands for nbr calculation")
1686
+ col = self.collection.map(
1687
+ lambda image: LandsatCollection.landsat_nbr_fn(
1688
+ image, threshold=threshold, ng_threshold=ng_threshold
1689
+ )
1690
+ )
1691
+ return LandsatCollection(collection=col)
1692
+
1693
+ @property
1694
+ def ndsi(self):
1695
+ """
1696
+ Property attribute to calculate and access the NDSI (Normalized Difference Snow Index) imagery of the LandsatCollection.
1697
+ This property initiates the calculation of NDSI and caches the result. The calculation is performed only once when the
1698
+ property is first accessed, and the cached result is returned on subsequent accesses.
1699
+
1700
+ Returns:
1701
+ LandsatCollection: A LandsatCollection image collection
1702
+ """
1703
+ if self._ndsi is None:
1704
+ self._ndsi = self.ndsi_collection(self.ndsi_threshold)
1705
+ return self._ndsi
1706
+
1707
+ def ndsi_collection(self, threshold, ng_threshold=None):
1708
+ """
1709
+ Function to calculate the NDSI (Normalized Difference Snow Index) and return collection as class object, allows specifying threshold(s) for masking.
1710
+ Thresholds can be specified for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5
1711
+ and the ng_threshold argument applies to Landsat 8&9. This function can be called as a method but is called
1712
+ by default when using the ndsi property attribute.
1713
+
1714
+ Args:
1715
+ threshold (float): specify threshold for NDSI function (values less than threshold are masked)
1716
+ ng_threshold (float, optional): specify threshold for Landsat 8&9 NDSI function (values less than threshold are masked)
1717
+
1718
+ Returns:
1719
+ LandsatCollection: A LandsatCollection image collection
1720
+ """
1721
+ first_image = self.collection.first()
1722
+ available_bands = first_image.bandNames()
1723
+ if available_bands.contains("SR_B3") and available_bands.contains("SR_B6"):
1724
+ pass
1725
+ else:
1726
+ raise ValueError("Insufficient Bands for ndsi calculation")
1727
+ col = self.collection.map(
1728
+ lambda image: LandsatCollection.landsat_ndsi_fn(
1729
+ image, threshold=threshold, ng_threshold=ng_threshold
1730
+ )
1731
+ )
1732
+ return LandsatCollection(collection=col)
1136
1733
 
1137
1734
  @property
1138
1735
  def halite(self):
@@ -1217,6 +1814,51 @@ class LandsatCollection:
1217
1814
  )
1218
1815
  )
1219
1816
  return LandsatCollection(collection=col)
1817
+
1818
+ @property
1819
+ def albedo(self):
1820
+ """
1821
+ Property attribute to calculate albedo imagery, based on Liang, 2001
1822
+ (https://doi.org/10.1016/S0034-4257(00)00205-4) and Wang et al., 2016 (https://doi.org/10.1016/j.rse.2016.02.059).
1823
+ This property initiates the calculation of albedo and caches the result. The calculation is performed only once when the property
1824
+ is first accessed, and the cached result is returned on subsequent accesses.
1825
+
1826
+ Returns:
1827
+ LandsatCollection: A LandsatCollection image collection
1828
+ """
1829
+ if self._albedo is None:
1830
+ self._albedo = self.albedo_collection()
1831
+ return self._albedo
1832
+
1833
+
1834
+ def albedo_collection(self):
1835
+ """
1836
+ Calculates albedo and returns collection as class object, allows specifying threshold(s) for masking.
1837
+ Thresholds can be specified for Landsat 5 vs 8&9 images, where the threshold argument applies to Landsat 5
1838
+ and the ng_threshold argument applies to Landsat 8&9. This function can be called as a method but is called
1839
+ by default when using the ndwi property attribute.
1840
+ Albedo calculation based on Liang, 2001 (https://doi.org/10.1016/S0034-4257(00)00205-4)
1841
+ and Wang et al., 2016 (https://doi.org/10.1016/j.rse.2016.02.059).
1842
+
1843
+ Returns:
1844
+ LandsatCollection: A LandsatCollection image collection
1845
+ """
1846
+ first_image = self.collection.first()
1847
+ available_bands = first_image.bandNames()
1848
+ if (
1849
+ available_bands.contains("SR_B1")
1850
+ and available_bands.contains("SR_B2")
1851
+ and available_bands.contains("SR_B3")
1852
+ and available_bands.contains("SR_B4")
1853
+ and available_bands.contains("SR_B5")
1854
+ and available_bands.contains("SR_B6")
1855
+ and available_bands.contains("SR_B7")
1856
+ ):
1857
+ pass
1858
+ else:
1859
+ raise ValueError("Insufficient Bands for albedo calculation")
1860
+ col = self.collection.map(LandsatCollection.landsat_broadband_albedo_fn)
1861
+ return LandsatCollection(collection=col)
1220
1862
 
1221
1863
  @property
1222
1864
  def turbidity(self):
@@ -1261,6 +1903,7 @@ class LandsatCollection:
1261
1903
 
1262
1904
  return LandsatCollection(collection=col)
1263
1905
 
1906
+
1264
1907
  @property
1265
1908
  def chlorophyll(self):
1266
1909
  """
@@ -1731,7 +2374,8 @@ class LandsatCollection:
1731
2374
  def ee_to_df(
1732
2375
  ee_object, columns=None, remove_geom=True, sort_columns=False, **kwargs
1733
2376
  ):
1734
- """Converts an ee.FeatureCollection to pandas dataframe. Adapted from the geemap package (https://geemap.org/common/#geemap.common.ee_to_df)
2377
+ """
2378
+ Converts an ee.FeatureCollection to pandas dataframe. Adapted from the geemap package (https://geemap.org/common/#geemap.common.ee_to_df)
1735
2379
 
1736
2380
  Args:
1737
2381
  ee_object (ee.FeatureCollection): ee.FeatureCollection.
@@ -1793,7 +2437,8 @@ class LandsatCollection:
1793
2437
  to_pandas=False,
1794
2438
  **kwargs,
1795
2439
  ):
1796
- """Extracts transect from an image. Adapted from the geemap package (https://geemap.org/common/#geemap.common.extract_transect). Exists as an alternative to RadGEEToolbox 'transect' function.
2440
+ """
2441
+ Extracts transect from an image. Adapted from the geemap package (https://geemap.org/common/#geemap.common.extract_transect).
1797
2442
 
1798
2443
  Args:
1799
2444
  image (ee.Image): The image to extract transect from.
@@ -1867,9 +2512,10 @@ class LandsatCollection:
1867
2512
  dist_interval=30,
1868
2513
  to_pandas=True,
1869
2514
  ):
1870
- """Computes and stores the values along a transect for each line in a list of lines. Builds off of the extract_transect function from the geemap package
1871
- where checks are ran to ensure that the reducer column is present in the transect data. If the reducer column is not present, a column of NaNs is created.
1872
- An ee reducer is used to aggregate the values along the transect, depending on the number of segments or distance interval specified. Defaults to 'mean' reducer.
2515
+ """
2516
+ Computes and stores the values along a transect for each line in a list of lines. Builds off of the extract_transect function from the geemap package
2517
+ where checks are ran to ensure that the reducer column is present in the transect data. If the reducer column is not present, a column of NaNs is created.
2518
+ An ee reducer is used to aggregate the values along the transect, depending on the number of segments or distance interval specified. Defaults to 'mean' reducer.
1873
2519
 
1874
2520
  Args:
1875
2521
  image (ee.Image): ee.Image object to use for calculating transect values.
@@ -1942,53 +2588,201 @@ class LandsatCollection:
1942
2588
  self,
1943
2589
  lines,
1944
2590
  line_names,
1945
- save_folder_path,
1946
2591
  reducer="mean",
1947
- n_segments=None,
1948
2592
  dist_interval=30,
1949
- to_pandas=True,
2593
+ n_segments=None,
2594
+ scale=30,
2595
+ processing_mode='aggregated',
2596
+ save_folder_path=None,
2597
+ sampling_method='line',
2598
+ point_buffer_radius=15
1950
2599
  ):
1951
- """Computes and stores the values along a transect for each line in a list of lines for each image in a LandsatCollection image collection, then saves the data for each image to a csv file. Builds off of the extract_transect function from the geemap package
1952
- where checks are ran to ensure that the reducer column is present in the transect data. If the reducer column is not present, a column of NaNs is created.
1953
- An ee reducer is used to aggregate the values along the transect, depending on the number of segments or distance interval specified. Defaults to 'mean' reducer.
1954
- Naming conventions for the csv files follows as: "image-date_transects.csv"
2600
+ """
2601
+ Computes and returns pixel values along transects for each image in a collection.
2602
+
2603
+ This iterative function generates time-series data along one or more lines, and
2604
+ supports two different geometric sampling methods ('line' and 'buffered_point')
2605
+ for maximum flexibility and performance.
2606
+
2607
+ There are two processing modes available, aggregated and iterative:
2608
+ - 'aggregated' (default; suggested): Fast, server-side processing. Fetches all results
2609
+ in a single request. Highly recommended. Returns a dictionary of pandas DataFrames.
2610
+ - 'iterative': Slower, client-side loop that processes one image at a time.
2611
+ Kept for backward compatibility (effectively depreciated). Returns None and saves individual CSVs.
2612
+ This method is not recommended unless absolutely necessary, as it is less efficient and may be subject to client-side timeouts.
1955
2613
 
1956
2614
  Args:
1957
- lines (list): List of ee.Geometry.LineString objects.
1958
- line_names (list of strings): List of line string names.
1959
- save_folder_path (str): The path to the folder where the csv files will be saved.
1960
- reducer (str): The ee reducer to use. Defaults to 'mean'.
1961
- n_segments (int): The number of segments that the LineString will be split into. Defaults to None.
1962
- dist_interval (float): The distance interval used for splitting the LineString. If specified, the n_segments parameter will be ignored. Defaults to 10.
1963
- to_pandas (bool): Whether to convert the result to a pandas dataframe. Defaults to True.
2615
+ lines (list): A list of one or more ee.Geometry.LineString objects that
2616
+ define the transects.
2617
+ line_names (list): A list of string names for each transect. The length
2618
+ of this list must match the length of the `lines` list.
2619
+ reducer (str, optional): The name of the ee.Reducer to apply at each
2620
+ transect point (e.g., 'mean', 'median', 'first'). Defaults to 'mean'.
2621
+ dist_interval (float, optional): The distance interval in meters for
2622
+ sampling points along each transect. Will be overridden if `n_segments` is provided.
2623
+ Defaults to 30. Recommended to increase this value when using the
2624
+ 'line' processing method, or else you may get blank rows.
2625
+ n_segments (int, optional): The number of equal-length segments to split
2626
+ each transect line into for sampling. This parameter overrides `dist_interval`.
2627
+ Defaults to None.
2628
+ scale (int, optional): The nominal scale in meters for the reduction,
2629
+ which should typically match the pixel resolution of the imagery.
2630
+ Defaults to 30.
2631
+ processing_mode (str, optional): The method for processing the collection.
2632
+ - 'aggregated' (default): Fast, server-side processing. Fetches all
2633
+ results in a single request. Highly recommended. Returns a dictionary
2634
+ of pandas DataFrames.
2635
+ - 'iterative': Slower, client-side loop that processes one image at a
2636
+ time. Kept for backward compatibility. Returns None and saves
2637
+ individual CSVs.
2638
+ save_folder_path (str, optional): If provided, the function will save the
2639
+ resulting transect data to CSV files. The behavior depends on the
2640
+ `processing_mode`:
2641
+ - In 'aggregated' mode, one CSV is saved for each transect,
2642
+ containing all dates. (e.g., 'MyTransect_transects.csv').
2643
+ - In 'iterative' mode, one CSV is saved for each date,
2644
+ containing all transects. (e.g., '2022-06-15_transects.csv').
2645
+ sampling_method (str, optional): The geometric method used for sampling.
2646
+ - 'line' (default): Reduces all pixels intersecting each small line
2647
+ segment. This can be unreliable and produce blank rows if
2648
+ `dist_interval` is too small relative to the `scale`.
2649
+ - 'buffered_point': Reduces all pixels within a buffer around the
2650
+ midpoint of each line segment. This method is more robust and
2651
+ reliably avoids blank rows, but may not reduce all pixels along a line segment.
2652
+ point_buffer_radius (int, optional): The radius in meters for the buffer
2653
+ when `sampling_method` is 'buffered_point'. Defaults to 15.
2654
+
2655
+ Returns:
2656
+ dict or None:
2657
+ - If `processing_mode` is 'aggregated', returns a dictionary where each
2658
+ key is a transect name and each value is a pandas DataFrame. In the
2659
+ DataFrame, the index is the distance along the transect and each
2660
+ column represents an image date. Optionally saves CSV files if
2661
+ `save_folder_path` is provided.
2662
+ - If `processing_mode` is 'iterative', returns None as it saves
2663
+ files directly.
1964
2664
 
1965
2665
  Raises:
1966
- Exception: If the program fails to compute.
1967
-
1968
- Returns:
1969
- csv file: file for each image with an organized list of values along the transect(s)
1970
- """
1971
- image_collection = self # .collection
1972
- # image_collection_dates = self._dates
1973
- image_collection_dates = self.dates
1974
- for i, date in enumerate(image_collection_dates):
2666
+ ValueError: If `lines` and `line_names` have different lengths, or if
2667
+ an unknown reducer or processing mode is specified.
2668
+ """
2669
+ # Validating inputs
2670
+ if len(lines) != len(line_names):
2671
+ raise ValueError("'lines' and 'line_names' must have the same number of elements.")
2672
+ ### Current, server-side processing method ###
2673
+ if processing_mode == 'aggregated':
2674
+ # Validating reducer type
1975
2675
  try:
1976
- print(f"Processing image {i+1}/{len(image_collection_dates)}: {date}")
1977
- image = image_collection.image_grab(i)
1978
- transects_df = LandsatCollection.transect(
1979
- image,
1980
- lines,
1981
- line_names,
1982
- reducer=reducer,
1983
- n_segments=n_segments,
1984
- dist_interval=dist_interval,
1985
- to_pandas=to_pandas,
1986
- )
1987
- image_id = date
1988
- transects_df.to_csv(f"{save_folder_path}{image_id}_transects.csv")
1989
- print(f"{image_id}_transects saved to csv")
1990
- except Exception as e:
1991
- print(f"An error occurred while processing image {i+1}: {e}")
2676
+ ee_reducer = getattr(ee.Reducer, reducer)()
2677
+ except AttributeError:
2678
+ raise ValueError(f"Unknown reducer: '{reducer}'.")
2679
+ ### Function to extract transects for a single image
2680
+ def get_transects_for_image(image):
2681
+ image_date = image.get('Date_Filter')
2682
+ # Initialize an empty list to hold all transect FeatureCollections
2683
+ all_transects_for_image = ee.List([])
2684
+ # Looping through each line and processing
2685
+ for i, line in enumerate(lines):
2686
+ # Index line and name
2687
+ line_name = line_names[i]
2688
+ # Determine maxError based on image projection, used for geometry operations
2689
+ maxError = image.projection().nominalScale().divide(5)
2690
+ # Calculate effective distance interval
2691
+ length = line.length(maxError) # using maxError here ensures consistency with cutLines
2692
+ # Determine effective distance interval based on n_segments or dist_interval
2693
+ effective_dist_interval = ee.Algorithms.If(
2694
+ n_segments,
2695
+ length.divide(n_segments),
2696
+ dist_interval or 30 # Defaults to 30 if both are None
2697
+ )
2698
+ # Generate distances along the line(s) for segmentation
2699
+ distances = ee.List.sequence(0, length, effective_dist_interval)
2700
+ # Segmenting the line into smaller lines at the specified distances
2701
+ cut_lines_geoms = line.cutLines(distances, maxError).geometries()
2702
+ # Function to create features with distance attributes
2703
+ # Adjusted to ensure consistent return types
2704
+ def set_dist_attr(l):
2705
+ # l is a list: [geometry, distance]
2706
+ # Extracting geometry portion of line
2707
+ geom_segment = ee.Geometry(ee.List(l).get(0))
2708
+ # Extracting distance value for attribute
2709
+ distance = ee.Number(ee.List(l).get(1))
2710
+ ### Determine final geometry based on sampling method
2711
+ # If the sampling method is 'buffered_point',
2712
+ # create a buffered point feature at the centroid of each segment,
2713
+ # otherwise create a line feature
2714
+ final_feature = ee.Algorithms.If(
2715
+ ee.String(sampling_method).equals('buffered_point'),
2716
+ # True Case: Create the buffered point feature
2717
+ ee.Feature(
2718
+ geom_segment.centroid(maxError).buffer(point_buffer_radius),
2719
+ {'distance': distance}
2720
+ ),
2721
+ # False Case: Create the line segment feature
2722
+ ee.Feature(geom_segment, {'distance': distance})
2723
+ )
2724
+ # Return either the line segment feature or the buffered point feature
2725
+ return final_feature
2726
+ # Creating a FeatureCollection of the cut lines with distance attributes
2727
+ # Using map to apply the set_dist_attr function to each cut line geometry
2728
+ line_features = ee.FeatureCollection(cut_lines_geoms.zip(distances).map(set_dist_attr))
2729
+ # Reducing the image over the line features to get transect values
2730
+ transect_fc = image.reduceRegions(
2731
+ collection=line_features, reducer=ee_reducer, scale=scale
2732
+ )
2733
+ # Adding image date and line name properties to each feature
2734
+ def set_props(feature):
2735
+ return feature.set({'image_date': image_date, 'transect_name': line_name})
2736
+ # Append to the list of all transects for this image
2737
+ all_transects_for_image = all_transects_for_image.add(transect_fc.map(set_props))
2738
+ # Combine all transect FeatureCollections into a single FeatureCollection and flatten
2739
+ # Flatten is used to merge the list of FeatureCollections into one
2740
+ return ee.FeatureCollection(all_transects_for_image).flatten()
2741
+ # Map the function over the entire image collection and flatten the results
2742
+ results_fc = ee.FeatureCollection(self.collection.map(get_transects_for_image)).flatten()
2743
+ # Convert the results to a pandas DataFrame
2744
+ df = LandsatCollection.ee_to_df(results_fc, remove_geom=True)
2745
+ # Check if the DataFrame is empty
2746
+ if df.empty:
2747
+ print("Warning: No transect data was generated.")
2748
+ return {}
2749
+ # Initialize dictionary to hold output DataFrames for each transect
2750
+ output_dfs = {}
2751
+ # Loop through each unique transect name and create a pivot table
2752
+ for name in sorted(df['transect_name'].unique()):
2753
+ transect_df = df[df['transect_name'] == name]
2754
+ pivot_df = transect_df.pivot(index='distance', columns='image_date', values=reducer)
2755
+ pivot_df.columns.name = 'Date'
2756
+ output_dfs[name] = pivot_df
2757
+ # Optionally save each transect DataFrame to CSV
2758
+ if save_folder_path:
2759
+ for transect_name, transect_df in output_dfs.items():
2760
+ safe_filename = "".join(x for x in transect_name if x.isalnum() or x in "._-")
2761
+ file_path = f"{save_folder_path}{safe_filename}_transects.csv"
2762
+ transect_df.to_csv(file_path)
2763
+ print(f"Saved transect data to {file_path}")
2764
+
2765
+ return output_dfs
2766
+
2767
+ ### old, depreciated iterative client-side processing method ###
2768
+ elif processing_mode == 'iterative':
2769
+ if not save_folder_path:
2770
+ raise ValueError("`save_folder_path` is required for 'iterative' processing mode.")
2771
+
2772
+ image_collection_dates = self.dates
2773
+ for i, date in enumerate(image_collection_dates):
2774
+ try:
2775
+ print(f"Processing image {i+1}/{len(image_collection_dates)}: {date}")
2776
+ image = self.image_grab(i)
2777
+ transects_df = LandsatCollection.transect(
2778
+ image, lines, line_names, reducer, n_segments, dist_interval, to_pandas=True
2779
+ )
2780
+ transects_df.to_csv(f"{save_folder_path}{date}_transects.csv")
2781
+ print(f"{date}_transects saved to csv")
2782
+ except Exception as e:
2783
+ print(f"An error occurred while processing image {i+1}: {e}")
2784
+ else:
2785
+ raise ValueError("`processing_mode` must be 'iterative' or 'aggregated'.")
1992
2786
 
1993
2787
  @staticmethod
1994
2788
  def extract_zonal_stats_from_buffer(
@@ -2001,37 +2795,35 @@ class LandsatCollection:
2001
2795
  coordinate_names=None,
2002
2796
  ):
2003
2797
  """
2004
- Function to extract spatial statistics from an image for a list of coordinates, providing individual statistics for each location.
2798
+ Function to extract spatial statistics from an image for a list or single set of (long, lat) coordinates, providing individual statistics for each location.
2005
2799
  A radial buffer is applied around each coordinate to extract the statistics, which defaults to 1 meter.
2006
2800
  The function returns a pandas DataFrame with the statistics for each coordinate.
2007
2801
 
2802
+ NOTE: Be sure the coordinates are provided as longitude, latitude (x, y) tuples!
2803
+
2008
2804
  Args:
2009
- image (ee.Image): The image from which to extract the statistics. Must be a singleband image or else resulting values will all be zero!
2010
- coordinates (list): Single tuple or list of tuples with the decimal degrees coordinates in the format of (longitude, latitude) for which to extract the statistics. NOTE the format needs to be [(x1, y1), (x2, y2), ...].
2011
- buffer_size (int, optional): The radial buffer size around the coordinates in meters. Defaults to 1.
2012
- reducer_type (str, optional): The reducer type to use. Defaults to 'mean'. Options are 'mean', 'median', 'min', and 'max'.
2013
- scale (int, optional): The scale to use. Defaults to 30.
2014
- tileScale (int, optional): The tile scale to use. Defaults to 1.
2015
- coordinate_names (list, optional): A list of strings with the names of the coordinates. Defaults to None.
2805
+ image (ee.Image): The image from which to extract statistics. Should be single-band.
2806
+ coordinates (list or tuple): A single (lon, lat) tuple or a list of (lon, lat) tuples.
2807
+ buffer_size (int, optional): The radial buffer size in meters. Defaults to 1.
2808
+ reducer_type (str, optional): The ee.Reducer to use ('mean', 'median', 'min', etc.). Defaults to 'mean'.
2809
+ scale (int, optional): The scale in meters for the reduction. Defaults to 30.
2810
+ tileScale (int, optional): The tile scale factor. Defaults to 1.
2811
+ coordinate_names (list, optional): A list of names for the coordinates.
2016
2812
 
2017
2813
  Returns:
2018
- pd.DataFrame: A pandas DataFrame with the statistics for each coordinate, each column name corresponds to the name of the coordinate feature (which may be blank if no names are supplied).
2814
+ pd.DataFrame: A pandas DataFrame with the image's 'Date_Filter' as the index and a
2815
+ column for each coordinate location.
2019
2816
  """
2020
-
2021
- # Check if coordinates is a single tuple and convert it to a list of tuples if necessary
2022
2817
  if isinstance(coordinates, tuple) and len(coordinates) == 2:
2023
2818
  coordinates = [coordinates]
2024
2819
  elif not (
2025
2820
  isinstance(coordinates, list)
2026
- and all(
2027
- isinstance(coord, tuple) and len(coord) == 2 for coord in coordinates
2028
- )
2821
+ and all(isinstance(coord, tuple) and len(coord) == 2 for coord in coordinates)
2029
2822
  ):
2030
2823
  raise ValueError(
2031
- "Coordinates must be a list of tuples with two elements each (latitude, longitude)."
2824
+ "Coordinates must be a list of tuples with two elements each (longitude, latitude)."
2032
2825
  )
2033
2826
 
2034
- # Check if coordinate_names is a list of strings
2035
2827
  if coordinate_names is not None:
2036
2828
  if not isinstance(coordinate_names, list) or not all(
2037
2829
  isinstance(name, str) for name in coordinate_names
@@ -2044,144 +2836,211 @@ class LandsatCollection:
2044
2836
  else:
2045
2837
  coordinate_names = [f"Location {i+1}" for i in range(len(coordinates))]
2046
2838
 
2047
- # Check if the image is a singleband image
2048
- def check_singleband(image):
2049
- band_count = image.bandNames().size()
2050
- return ee.Algorithms.If(band_count.eq(1), image, ee.Image.constant(0))
2051
-
2052
- # image = ee.Image(check_singleband(image))
2053
- image = ee.Image(check_singleband(image))
2839
+ image_date = image.get('Date_Filter')
2054
2840
 
2055
- # Convert coordinates to ee.Geometry.Point, buffer them, and add label/name to feature
2056
2841
  points = [
2057
2842
  ee.Feature(
2058
- ee.Geometry.Point([coord[0], coord[1]]).buffer(buffer_size),
2059
- {"name": str(coordinate_names[i])},
2843
+ ee.Geometry.Point(coord).buffer(buffer_size),
2844
+ {"location_name": str(name)},
2060
2845
  )
2061
- for i, coord in enumerate(coordinates)
2846
+ for coord, name in zip(coordinates, coordinate_names)
2062
2847
  ]
2063
- # Create a feature collection from the buffered points
2064
2848
  features = ee.FeatureCollection(points)
2065
- # Reduce the image to the buffered points - handle different reducer types
2066
- if reducer_type == "mean":
2067
- img_stats = image.reduceRegions(
2068
- collection=features,
2069
- reducer=ee.Reducer.mean(),
2070
- scale=scale,
2071
- tileScale=tileScale,
2072
- )
2073
- mean_values = img_stats.getInfo()
2074
- means = []
2075
- names = []
2076
- for feature in mean_values["features"]:
2077
- names.append(feature["properties"]["name"])
2078
- means.append(feature["properties"]["mean"])
2079
- organized_values = pd.DataFrame([means], columns=names)
2080
- elif reducer_type == "median":
2081
- img_stats = image.reduceRegions(
2082
- collection=features,
2083
- reducer=ee.Reducer.median(),
2084
- scale=scale,
2085
- tileScale=tileScale,
2086
- )
2087
- median_values = img_stats.getInfo()
2088
- medians = []
2089
- names = []
2090
- for feature in median_values["features"]:
2091
- names.append(feature["properties"]["name"])
2092
- medians.append(feature["properties"]["median"])
2093
- organized_values = pd.DataFrame([medians], columns=names)
2094
- elif reducer_type == "min":
2095
- img_stats = image.reduceRegions(
2096
- collection=features,
2097
- reducer=ee.Reducer.min(),
2098
- scale=scale,
2099
- tileScale=tileScale,
2100
- )
2101
- min_values = img_stats.getInfo()
2102
- mins = []
2103
- names = []
2104
- for feature in min_values["features"]:
2105
- names.append(feature["properties"]["name"])
2106
- mins.append(feature["properties"]["min"])
2107
- organized_values = pd.DataFrame([mins], columns=names)
2108
- elif reducer_type == "max":
2109
- img_stats = image.reduceRegions(
2110
- collection=features,
2111
- reducer=ee.Reducer.max(),
2112
- scale=scale,
2113
- tileScale=tileScale,
2114
- )
2115
- max_values = img_stats.getInfo()
2116
- maxs = []
2117
- names = []
2118
- for feature in max_values["features"]:
2119
- names.append(feature["properties"]["name"])
2120
- maxs.append(feature["properties"]["max"])
2121
- organized_values = pd.DataFrame([maxs], columns=names)
2122
- else:
2123
- raise ValueError(
2124
- "reducer_type must be one of 'mean', 'median', 'min', or 'max'."
2125
- )
2126
- return organized_values
2849
+
2850
+ try:
2851
+ reducer = getattr(ee.Reducer, reducer_type)()
2852
+ except AttributeError:
2853
+ raise ValueError(f"Unknown reducer_type: '{reducer_type}'.")
2854
+
2855
+ stats_fc = image.reduceRegions(
2856
+ collection=features,
2857
+ reducer=reducer,
2858
+ scale=scale,
2859
+ tileScale=tileScale,
2860
+ )
2861
+
2862
+ df = LandsatCollection.ee_to_df(stats_fc, remove_geom=True)
2863
+
2864
+ if df.empty:
2865
+ print("Warning: No results returned. The points may not intersect the image.")
2866
+ empty_df = pd.DataFrame(columns=coordinate_names)
2867
+ empty_df.index.name = 'Date'
2868
+ return empty_df
2869
+
2870
+ if reducer_type not in df.columns:
2871
+ print(f"Warning: Reducer type '{reducer_type}' not found in results. Returning raw data.")
2872
+ return df
2873
+
2874
+ pivot_df = df.pivot(columns='location_name', values=reducer_type)
2875
+ pivot_df['Date'] = image_date.getInfo() # .getInfo() is needed here as it's a server object
2876
+ pivot_df = pivot_df.set_index('Date')
2877
+ return pivot_df
2127
2878
 
2128
2879
  def iterate_zonal_stats(
2129
2880
  self,
2130
- coordinates,
2131
- buffer_size=1,
2881
+ geometries,
2882
+ band=None,
2132
2883
  reducer_type="mean",
2133
2884
  scale=30,
2885
+ geometry_names=None,
2886
+ buffer_size=1,
2134
2887
  tileScale=1,
2135
- coordinate_names=None,
2136
- file_path=None,
2137
2888
  dates=None,
2889
+ file_path=None
2138
2890
  ):
2139
2891
  """
2140
- Function to iterate over a collection of images and extract spatial statistics for a list of coordinates (defaults to mean). Individual statistics are provided for each location.
2141
- A radial buffer is applied around each coordinate to extract the statistics, which defaults to 1 meter.
2892
+ Iterates over a collection of images and extracts spatial statistics (defaults to mean) for a given list of geometries or coordinates. Individual statistics are calculated for each geometry or coordinate provided.
2893
+ When coordinates are provided, a radial buffer is applied around each coordinate to extract the statistics, where the size of the buffer is determined by the buffer_size argument (defaults to 1 meter).
2142
2894
  The function returns a pandas DataFrame with the statistics for each coordinate and date, or optionally exports the data to a table in .csv format.
2143
2895
 
2144
2896
  Args:
2145
- coordinates (list): Single tuple or a list of tuples with the coordinates as decimal degrees in the format of (longitude, latitude) for which to extract the statistics. NOTE the format needs to be [(x1, y1), (x2, y2), ...].
2146
- buffer_size (int, optional): The radial buffer size in meters around the coordinates. Defaults to 1.
2147
- reducer_type (str, optional): The reducer type to use. Defaults to 'mean'. Options are 'mean', 'median', 'min', and 'max'.
2148
- scale (int, optional): The scale (pixel size) to use in meters. Defaults to 30.
2149
- tileScale (int, optional): The tile scale to use. Defaults to 1.
2150
- coordinate_names (list, optional): A list of strings with the names of the coordinates. Defaults to None.
2151
- file_path (str, optional): The file path to export the data to. Defaults to None. Ensure ".csv" is NOT included in the file name path.
2152
- dates (list, optional): A list of dates for which to extract the statistics. Defaults to None.
2153
-
2154
- Returns:
2155
- pd.DataFrame: A pandas DataFrame with the statistics for each coordinate and date, each row corresponds to a date and each column to a coordinate.
2156
- .csv file: Optionally exports the data to a table in .csv format. If file_path is None, the function returns the DataFrame - otherwise the function will only export the csv file.
2157
- """
2158
- img_collection = self
2159
- # Create empty DataFrame to accumulate results
2160
- accumulated_df = pd.DataFrame()
2161
- # Check if dates is None, if not use the dates provided
2162
- if dates is None:
2163
- dates = img_collection.dates
2164
- else:
2165
- dates = dates
2166
- # Iterate over the dates and extract the zonal statistics for each date
2167
- for date in dates:
2168
- image = img_collection.collection.filter(
2169
- ee.Filter.eq("Date_Filter", date)
2170
- ).first()
2171
- single_df = LandsatCollection.extract_zonal_stats_from_buffer(
2172
- image,
2173
- coordinates,
2174
- buffer_size=buffer_size,
2175
- reducer_type=reducer_type,
2176
- scale=scale,
2177
- tileScale=tileScale,
2178
- coordinate_names=coordinate_names,
2897
+ geometries (ee.Geometry, ee.Feature, ee.FeatureCollection, list, or tuple): Input geometries for which to extract statistics. Can be a single ee.Geometry, an ee.Feature, an ee.FeatureCollection, a list of (lon, lat) tuples, or a list of ee.Geometry objects. Be careful to NOT provide coordinates as (lat, lon)!
2898
+ band (str, optional): The name of the band to use for statistics. If None, the first band is used. Defaults to None.
2899
+ reducer_type (str, optional): The ee.Reducer to use, e.g., 'mean', 'median', 'max', 'sum'. Defaults to 'mean'. Any ee.Reducer method can be used.
2900
+ scale (int, optional): Pixel scale in meters for the reduction. Defaults to 30.
2901
+ geometry_names (list, optional): A list of string names for the geometries. If provided, must match the number of geometries. Defaults to None.
2902
+ buffer_size (int, optional): Radial buffer in meters around coordinates. Defaults to 1.
2903
+ tileScale (int, optional): A scaling factor to reduce aggregation tile size. Defaults to 1.
2904
+ dates (list, optional): A list of date strings ('YYYY-MM-DD') for filtering the collection, such that only images from these dates are included for zonal statistic retrieval. Defaults to None, which uses all dates in the collection.
2905
+ file_path (str, optional): File path to save the output CSV.
2906
+
2907
+ Returns:
2908
+ pd.DataFrame or None: A pandas DataFrame with dates as the index and coordinate names
2909
+ as columns. Returns None if using 'iterative' mode with file_path.
2910
+
2911
+ Raises:
2912
+ ValueError: If input parameters are invalid.
2913
+ TypeError: If geometries input type is unsupported.
2914
+ """
2915
+ img_collection_obj = self
2916
+ if band:
2917
+ img_collection_obj = LandsatCollection(collection=img_collection_obj.collection.select(band))
2918
+ else:
2919
+ first_image = img_collection_obj.image_grab(0)
2920
+ first_band = first_image.bandNames().get(0)
2921
+ img_collection_obj = LandsatCollection(collection=img_collection_obj.collection.select([first_band]))
2922
+ # Filter collection by dates if provided
2923
+ if dates:
2924
+ img_collection_obj = LandsatCollection(
2925
+ collection=self.collection.filter(ee.Filter.inList('Date_Filter', dates))
2179
2926
  )
2180
- single_df["Date"] = date
2181
- single_df.set_index("Date", inplace=True)
2182
- accumulated_df = pd.concat([accumulated_df, single_df])
2183
- # Return the DataFrame or export the data to a .csv file
2184
- if file_path is None:
2185
- return accumulated_df
2927
+
2928
+ # Initialize variables
2929
+ features = None
2930
+ validated_coordinates = []
2931
+
2932
+ # Function to standardize feature names if no names are provided
2933
+ def set_standard_name(feature):
2934
+ has_geo_name = feature.get('geo_name')
2935
+ has_name = feature.get('name')
2936
+ has_index = feature.get('system:index')
2937
+ new_name = ee.Algorithms.If(
2938
+ has_geo_name, has_geo_name,
2939
+ ee.Algorithms.If(has_name, has_name,
2940
+ ee.Algorithms.If(has_index, has_index, 'unnamed_geometry')))
2941
+ return feature.set({'geo_name': new_name})
2942
+
2943
+ if isinstance(geometries, (ee.FeatureCollection, ee.Feature)):
2944
+ features = ee.FeatureCollection(geometries)
2945
+ if geometry_names:
2946
+ print("Warning: 'geometry_names' are ignored when the input is an ee.Feature or ee.FeatureCollection.")
2947
+
2948
+ elif isinstance(geometries, ee.Geometry):
2949
+ name = geometry_names[0] if (geometry_names and geometry_names[0]) else 'unnamed_geometry'
2950
+ features = ee.FeatureCollection([ee.Feature(geometries).set('geo_name', name)])
2951
+
2952
+ elif isinstance(geometries, list):
2953
+ if not geometries: # Handle empty list case
2954
+ raise ValueError("'geometries' list cannot be empty.")
2955
+
2956
+ # Case: List of coordinates
2957
+ if all(isinstance(i, tuple) for i in geometries):
2958
+ validated_coordinates = geometries
2959
+ if geometry_names is None:
2960
+ geometry_names = [f"Location_{i+1}" for i in range(len(validated_coordinates))]
2961
+ elif len(geometry_names) != len(validated_coordinates):
2962
+ raise ValueError("geometry_names must have the same length as the coordinates list.")
2963
+ points = [
2964
+ ee.Feature(ee.Geometry.Point(coord).buffer(buffer_size), {'geo_name': str(name)})
2965
+ for coord, name in zip(validated_coordinates, geometry_names)
2966
+ ]
2967
+ features = ee.FeatureCollection(points)
2968
+
2969
+ # Case: List of Geometries
2970
+ elif all(isinstance(i, ee.Geometry) for i in geometries):
2971
+ if geometry_names is None:
2972
+ geometry_names = [f"Geometry_{i+1}" for i in range(len(geometries))]
2973
+ elif len(geometry_names) != len(geometries):
2974
+ raise ValueError("geometry_names must have the same length as the geometries list.")
2975
+ geom_features = [
2976
+ ee.Feature(geom).set({'geo_name': str(name)})
2977
+ for geom, name in zip(geometries, geometry_names)
2978
+ ]
2979
+ features = ee.FeatureCollection(geom_features)
2980
+
2981
+ else:
2982
+ raise TypeError("Input list must be a list of (lon, lat) tuples OR a list of ee.Geometry objects.")
2983
+
2984
+ elif isinstance(geometries, tuple) and len(geometries) == 2:
2985
+ name = geometry_names[0] if geometry_names else 'Location_1'
2986
+ features = ee.FeatureCollection([
2987
+ ee.Feature(ee.Geometry.Point(geometries).buffer(buffer_size), {'geo_name': name})
2988
+ ])
2186
2989
  else:
2187
- return accumulated_df.to_csv(f"{file_path}.csv")
2990
+ raise TypeError("Unsupported type for 'geometries'.")
2991
+
2992
+ features = features.map(set_standard_name)
2993
+
2994
+ try:
2995
+ reducer = getattr(ee.Reducer, reducer_type)()
2996
+ except AttributeError:
2997
+ raise ValueError(f"Unknown reducer_type: '{reducer_type}'.")
2998
+
2999
+ def calculate_stats_for_image(image):
3000
+ image_date = image.get('Date_Filter')
3001
+ stats_fc = image.reduceRegions(
3002
+ collection=features, reducer=reducer, scale=scale, tileScale=tileScale
3003
+ )
3004
+
3005
+ def guarantee_reducer_property(f):
3006
+ has_property = f.propertyNames().contains(reducer_type)
3007
+ return ee.Algorithms.If(has_property, f, f.set(reducer_type, -9999))
3008
+ fixed_stats_fc = stats_fc.map(guarantee_reducer_property)
3009
+
3010
+ return fixed_stats_fc.map(lambda f: f.set('image_date', image_date))
3011
+
3012
+ results_fc = ee.FeatureCollection(img_collection_obj.collection.map(calculate_stats_for_image)).flatten()
3013
+ df = LandsatCollection.ee_to_df(results_fc, remove_geom=True)
3014
+
3015
+ # Checking for issues
3016
+ if df.empty:
3017
+ # print("No results found for the given parameters. Check if the geometries intersect with the images, if the dates filter is too restrictive, or if the provided bands are empty.")
3018
+ # return df
3019
+ raise ValueError("No results found for the given parameters. Check if the geometries intersect with the images, if the dates filter is too restrictive, or if the provided bands are empty.")
3020
+ if reducer_type not in df.columns:
3021
+ print(f"Warning: Reducer '{reducer_type}' not found in results.")
3022
+ # return df
3023
+
3024
+ # Get the number of rows before dropping nulls for a helpful message
3025
+ initial_rows = len(df)
3026
+ df.dropna(subset=[reducer_type], inplace=True)
3027
+ df = df[df[reducer_type] != -9999]
3028
+ dropped_rows = initial_rows - len(df)
3029
+ if dropped_rows > 0:
3030
+ print(f"Warning: Discarded {dropped_rows} results due to failed reductions (e.g., no valid pixels in geometry).")
3031
+
3032
+ # Reshape DataFrame to have dates as index and geometry names as columns
3033
+ pivot_df = df.pivot(index='image_date', columns='geo_name', values=reducer_type)
3034
+ pivot_df.index.name = 'Date'
3035
+ if file_path:
3036
+ # Check if file_path ends with .csv and remove it if so for consistency
3037
+ if file_path.endswith('.csv'):
3038
+ file_path = file_path[:-4]
3039
+ pivot_df.to_csv(f"{file_path}.csv")
3040
+ print(f"Zonal stats saved to {file_path}.csv")
3041
+ return
3042
+ return pivot_df
3043
+
3044
+
3045
+
3046
+