RadGEEToolbox 1.7.3__py3-none-any.whl → 1.7.5__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.
@@ -1,6 +1,7 @@
1
1
  import ee
2
2
  import pandas as pd
3
3
  import numpy as np
4
+ import warnings
4
5
 
5
6
 
6
7
  # ---- Reflectance scaling for Sentinel-2 L2A (HARMONIZED) ----
@@ -68,7 +69,7 @@ class Sentinel2Collection:
68
69
  ... cloud_percentage_threshold=20,
69
70
  ... nodata_threshold=10,
70
71
  ... )
71
- >>> mosaic_collection = image_collection.MosaicByDate #mosaic images/tiles with same date
72
+ >>> mosaic_collection = image_collection.mosaicByDate #mosaic images/tiles with same date
72
73
  >>> cloud_masked = mosaic_collection.masked_clouds_collection #mask out clouds
73
74
  >>> latest_image = cloud_masked.image_grab(-1) #grab latest image for viewing
74
75
  >>> ndwi_collection = cloud_masked.ndwi #calculate ndwi for all images
@@ -196,6 +197,14 @@ class Sentinel2Collection:
196
197
  self._PixelAreaSumCollection = None
197
198
  self._Reflectance = None
198
199
 
200
+ def __call__(self):
201
+ """
202
+ Allows the object to be called as a function, returning itself.
203
+ This enables property-like methods to be accessed with or without parentheses
204
+ (e.g., .mosaicByDate or .mosaicByDate()).
205
+ """
206
+ return self
207
+
199
208
  @staticmethod
200
209
  def image_dater(image):
201
210
  """
@@ -564,7 +573,7 @@ class Sentinel2Collection:
564
573
  return image.addBands(anomaly_image, overwrite=True)
565
574
 
566
575
  @staticmethod
567
- def MaskCloudsS2(image):
576
+ def maskClouds(image):
568
577
  """
569
578
  Function to mask clouds using SCL band data.
570
579
 
@@ -579,7 +588,14 @@ class Sentinel2Collection:
579
588
  return image.updateMask(CloudMask).copyProperties(image).set('system:time_start', image.get('system:time_start'))
580
589
 
581
590
  @staticmethod
582
- def MaskShadowsS2(image):
591
+ def MaskCloudsS2(image):
592
+ warnings.warn("MaskCloudsS2 is deprecated. Please use maskClouds instead.",
593
+ DeprecationWarning,
594
+ stacklevel=2)
595
+ return Sentinel2Collection.maskClouds(image)
596
+
597
+ @staticmethod
598
+ def maskShadows(image):
583
599
  """
584
600
  Function to mask cloud shadows using SCL band data.
585
601
 
@@ -592,9 +608,16 @@ class Sentinel2Collection:
592
608
  SCL = image.select("SCL")
593
609
  ShadowMask = SCL.neq(3)
594
610
  return image.updateMask(ShadowMask).copyProperties(image).set('system:time_start', image.get('system:time_start'))
611
+
612
+ @staticmethod
613
+ def MaskShadowsS2(image):
614
+ warnings.warn("MaskShadowsS2 is deprecated. Please use maskShadows instead.",
615
+ DeprecationWarning,
616
+ stacklevel=2)
617
+ return Sentinel2Collection.maskShadows(image)
595
618
 
596
619
  @staticmethod
597
- def MaskWaterS2(image):
620
+ def maskWater(image):
598
621
  """
599
622
  Function to mask water pixels using SCL band data.
600
623
 
@@ -607,9 +630,16 @@ class Sentinel2Collection:
607
630
  SCL = image.select("SCL")
608
631
  WaterMask = SCL.neq(6)
609
632
  return image.updateMask(WaterMask).copyProperties(image).set('system:time_start', image.get('system:time_start'))
633
+
634
+ @staticmethod
635
+ def MaskWaterS2(image):
636
+ warnings.warn("MaskWaterS2 is deprecated. Please use maskWater instead.",
637
+ DeprecationWarning,
638
+ stacklevel=2)
639
+ return Sentinel2Collection.maskWater(image)
610
640
 
611
641
  @staticmethod
612
- def MaskWaterS2ByNDWI(image, threshold):
642
+ def maskWaterByNDWI(image, threshold):
613
643
  """
614
644
  Function to mask water pixels (mask land and cloud pixels) for all bands based on NDWI and a set threshold where
615
645
  all pixels less than NDWI threshold are masked out.
@@ -626,9 +656,16 @@ class Sentinel2Collection:
626
656
  ) # green-NIR / green+NIR -- full NDWI image
627
657
  water = image.updateMask(ndwi_calc.lt(threshold)).copyProperties(image).set('system:time_start', image.get('system:time_start'))
628
658
  return water
659
+
660
+ @staticmethod
661
+ def MaskWaterS2ByNDWI(image, threshold):
662
+ warnings.warn("MaskWaterS2ByNDWI is deprecated. Please use maskWaterByNDWI instead.",
663
+ DeprecationWarning,
664
+ stacklevel=2)
665
+ return Sentinel2Collection.maskWaterByNDWI(image, threshold)
629
666
 
630
667
  @staticmethod
631
- def MaskToWaterS2(image):
668
+ def maskToWater(image):
632
669
  """
633
670
  Function to mask to water pixels (mask land and cloud pixels) using SCL band data.
634
671
 
@@ -641,6 +678,13 @@ class Sentinel2Collection:
641
678
  SCL = image.select("SCL")
642
679
  WaterMask = SCL.eq(6)
643
680
  return image.updateMask(WaterMask).copyProperties(image).set('system:time_start', image.get('system:time_start'))
681
+
682
+ @staticmethod
683
+ def MaskToWaterS2(image):
684
+ warnings.warn("MaskToWaterS2 is deprecated. Please use maskToWater instead.",
685
+ DeprecationWarning,
686
+ stacklevel=2)
687
+ return Sentinel2Collection.maskToWater(image)
644
688
 
645
689
  @staticmethod
646
690
  def halite_mask(image, threshold):
@@ -748,7 +792,7 @@ class Sentinel2Collection:
748
792
  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'))
749
793
 
750
794
  @staticmethod
751
- def MaskToWaterS2ByNDWI(image, threshold):
795
+ def maskToWaterByNDWI(image, threshold):
752
796
  """
753
797
  Function to mask all bands to water pixels (mask land and cloud pixels) based on NDWI.
754
798
 
@@ -764,9 +808,16 @@ class Sentinel2Collection:
764
808
  ) # green-NIR / green+NIR -- full NDWI image
765
809
  water = image.updateMask(ndwi_calc.gte(threshold)).copyProperties(image).set('system:time_start', image.get('system:time_start'))
766
810
  return water
811
+
812
+ @staticmethod
813
+ def MaskToWaterS2ByNDWI(image, threshold):
814
+ warnings.warn("MaskToWaterS2ByNDWI is deprecated. Please use maskToWaterByNDWI instead.",
815
+ DeprecationWarning,
816
+ stacklevel=2)
817
+ return Sentinel2Collection.maskToWaterByNDWI(image, threshold)
767
818
 
768
819
  @staticmethod
769
- def PixelAreaSum(
820
+ def pixelAreaSum(
770
821
  image, band_name, geometry, threshold=-1, scale=10, maxPixels=1e12
771
822
  ):
772
823
  """
@@ -825,8 +876,17 @@ class Sentinel2Collection:
825
876
  # Call to iterate the calculate_and_set_area function over the list of bands, starting with the original image
826
877
  final_image = ee.Image(bands.iterate(calculate_and_set_area, image))
827
878
  return final_image
879
+
880
+ @staticmethod
881
+ def PixelAreaSum(
882
+ image, band_name, geometry, threshold=-1, scale=10, maxPixels=1e12
883
+ ):
884
+ warnings.warn("PixelAreaSum is deprecated. Please use pixelAreaSum instead.",
885
+ DeprecationWarning,
886
+ stacklevel=2)
887
+ return Sentinel2Collection.pixelAreaSum(image, band_name, geometry, threshold, scale, maxPixels)
828
888
 
829
- def PixelAreaSumCollection(
889
+ def pixelAreaSumCollection(
830
890
  self, band_name, geometry, threshold=-1, scale=10, maxPixels=1e12, output_type='ImageCollection', area_data_export_path=None
831
891
  ):
832
892
  """
@@ -853,7 +913,7 @@ class Sentinel2Collection:
853
913
  collection = self.collection
854
914
  # Area calculation for each image in the collection, using the PixelAreaSum function
855
915
  AreaCollection = collection.map(
856
- lambda image: Sentinel2Collection.PixelAreaSum(
916
+ lambda image: Sentinel2Collection.pixelAreaSum(
857
917
  image,
858
918
  band_name=band_name,
859
919
  geometry=geometry,
@@ -869,17 +929,25 @@ class Sentinel2Collection:
869
929
 
870
930
  # If an export path is provided, the area data will be exported to a CSV file
871
931
  if area_data_export_path:
872
- Sentinel2Collection(collection=self._PixelAreaSumCollection).ExportProperties(property_names=prop_names, file_path=area_data_export_path+'.csv')
932
+ Sentinel2Collection(collection=self._PixelAreaSumCollection).exportProperties(property_names=prop_names, file_path=area_data_export_path+'.csv')
873
933
  # Returning the result in the desired format based on output_type argument or raising an error for invalid input
874
934
  if output_type == 'ImageCollection' or output_type == 'ee.ImageCollection':
875
935
  return self._PixelAreaSumCollection
876
936
  elif output_type == 'Sentinel2Collection':
877
937
  return Sentinel2Collection(collection=self._PixelAreaSumCollection)
878
938
  elif output_type == 'DataFrame' or output_type == 'Pandas' or output_type == 'pd' or output_type == 'dataframe' or output_type == 'df':
879
- return Sentinel2Collection(collection=self._PixelAreaSumCollection).ExportProperties(property_names=prop_names)
939
+ return Sentinel2Collection(collection=self._PixelAreaSumCollection).exportProperties(property_names=prop_names)
880
940
  else:
881
941
  raise ValueError("Incorrect `output_type`. The `output_type` argument must be one of the following: 'ImageCollection', 'ee.ImageCollection', 'Sentinel2Collection', 'DataFrame', 'Pandas', 'pd', 'dataframe', or 'df'.")
882
942
 
943
+ def PixelAreaSumCollection(
944
+ self, band_name, geometry, threshold=-1, scale=10, maxPixels=1e12, output_type='ImageCollection', area_data_export_path=None
945
+ ):
946
+ warnings.warn("PixelAreaSumCollection is deprecated. Please use pixelAreaSumCollection instead.",
947
+ DeprecationWarning,
948
+ stacklevel=2)
949
+ return self.pixelAreaSumCollection(band_name, geometry, threshold, scale, maxPixels, output_type, area_data_export_path)
950
+
883
951
  @staticmethod
884
952
  def add_month_property_fn(image):
885
953
  """
@@ -960,8 +1028,13 @@ class Sentinel2Collection:
960
1028
  return Sentinel2Collection(collection=ee.ImageCollection(paired.map(_pair_two)))
961
1029
 
962
1030
  # Preferred path: merge many singleband products into the parent
963
- if not isinstance(collections, list) or len(collections) == 0:
964
- raise ValueError("Provide a non-empty list of Sentinel2Collection objects in `collections`.")
1031
+ # if not isinstance(collections, list) or len(collections) == 0:
1032
+ # raise ValueError("Provide a non-empty list of Sentinel2Collection objects in `collections`.")
1033
+ if not isinstance(collections, list):
1034
+ collections = [collections]
1035
+
1036
+ if len(collections) == 0:
1037
+ raise ValueError("Provide a non-empty list of LandsatCollection objects in `collections`.")
965
1038
 
966
1039
  result = self.collection
967
1040
  for extra in collections:
@@ -1018,7 +1091,7 @@ class Sentinel2Collection:
1018
1091
  self._dates = dates
1019
1092
  return self._dates
1020
1093
 
1021
- def ExportProperties(self, property_names, file_path=None):
1094
+ def exportProperties(self, property_names, file_path=None):
1022
1095
  """
1023
1096
  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.
1024
1097
 
@@ -1073,6 +1146,13 @@ class Sentinel2Collection:
1073
1146
  print(f"Properties saved to {file_path}")
1074
1147
 
1075
1148
  return df
1149
+
1150
+ def ExportProperties(self, property_names, file_path=None):
1151
+ warnings.warn(
1152
+ "The `ExportProperties` method is deprecated and will be removed in future versions. Please use the `exportProperties` method instead.",
1153
+ DeprecationWarning,
1154
+ stacklevel=2)
1155
+ return self.exportProperties(property_names, file_path)
1076
1156
 
1077
1157
  def get_filtered_collection(self):
1078
1158
  """
@@ -2678,7 +2758,7 @@ class Sentinel2Collection:
2678
2758
  Sentinel2Collection: Sentinel2Collection image collection.
2679
2759
  """
2680
2760
  if self._masked_water_collection is None:
2681
- col = self.collection.map(Sentinel2Collection.MaskWaterS2)
2761
+ col = self.collection.map(Sentinel2Collection.maskWater)
2682
2762
  self._masked_water_collection = Sentinel2Collection(collection=col)
2683
2763
  return self._masked_water_collection
2684
2764
 
@@ -2690,7 +2770,7 @@ class Sentinel2Collection:
2690
2770
  Sentinel2Collection: Sentinel2Collection image collection.
2691
2771
  """
2692
2772
  col = self.collection.map(
2693
- lambda image: Sentinel2Collection.MaskWaterS2ByNDWI(
2773
+ lambda image: Sentinel2Collection.maskWaterByNDWI(
2694
2774
  image, threshold=threshold
2695
2775
  )
2696
2776
  )
@@ -2705,7 +2785,7 @@ class Sentinel2Collection:
2705
2785
  Sentinel2Collection: Sentinel2Collection image collection.
2706
2786
  """
2707
2787
  if self._masked_to_water_collection is None:
2708
- col = self.collection.map(Sentinel2Collection.MaskToWaterS2)
2788
+ col = self.collection.map(Sentinel2Collection.maskToWater)
2709
2789
  self._masked_water_collection = Sentinel2Collection(collection=col)
2710
2790
  return self._masked_water_collection
2711
2791
 
@@ -2717,7 +2797,7 @@ class Sentinel2Collection:
2717
2797
  Sentinel2Collection: Sentinel2Collection image collection.
2718
2798
  """
2719
2799
  col = self.collection.map(
2720
- lambda image: Sentinel2Collection.MaskToWaterS2ByNDWI(
2800
+ lambda image: Sentinel2Collection.maskToWaterByNDWI(
2721
2801
  image, threshold=threshold
2722
2802
  )
2723
2803
  )
@@ -2732,7 +2812,7 @@ class Sentinel2Collection:
2732
2812
  Sentinel2Collection: masked Sentinel2Collection image collection.
2733
2813
  """
2734
2814
  if self._masked_clouds_collection is None:
2735
- col = self.collection.map(Sentinel2Collection.MaskCloudsS2)
2815
+ col = self.collection.map(Sentinel2Collection.maskClouds)
2736
2816
  self._masked_clouds_collection = Sentinel2Collection(collection=col)
2737
2817
  return self._masked_clouds_collection
2738
2818
 
@@ -2745,7 +2825,7 @@ class Sentinel2Collection:
2745
2825
  Sentinel2Collection: Sentinel2Collection image collection
2746
2826
  """
2747
2827
  if self._masked_shadows_collection is None:
2748
- col = self.collection.map(Sentinel2Collection.MaskShadowsS2)
2828
+ col = self.collection.map(Sentinel2Collection.maskShadows)
2749
2829
  self._masked_shadows_collection = Sentinel2Collection(collection=col)
2750
2830
  return self._masked_shadows_collection
2751
2831
 
@@ -2760,20 +2840,15 @@ class Sentinel2Collection:
2760
2840
  Sentinel2Collection: masked Sentinel2Collection image collection.
2761
2841
 
2762
2842
  """
2763
- if self._geometry_masked_collection is None:
2764
- # Convert the polygon to a mask
2765
- mask = ee.Image.constant(1).clip(polygon)
2766
-
2767
- # Update the mask of each image in the collection
2768
- masked_collection = self.collection.map(lambda img: img.updateMask(mask))
2843
+ # Convert the polygon to a mask
2844
+ mask = ee.Image.constant(1).clip(polygon)
2769
2845
 
2770
- # Update the internal collection state
2771
- self._geometry_masked_collection = Sentinel2Collection(
2772
- collection=masked_collection
2773
- )
2846
+ # Update the mask of each image in the collection
2847
+ masked_collection = self.collection.map(lambda img: img.updateMask(mask)\
2848
+ .copyProperties(img).set('system:time_start', img.get('system:time_start')))
2774
2849
 
2775
2850
  # Return the updated object
2776
- return self._geometry_masked_collection
2851
+ return Sentinel2Collection(collection=masked_collection)
2777
2852
 
2778
2853
  def mask_out_polygon(self, polygon):
2779
2854
  """
@@ -2786,23 +2861,17 @@ class Sentinel2Collection:
2786
2861
  Sentinel2Collection: masked Sentinel2Collection image collection.
2787
2862
 
2788
2863
  """
2789
- if self._geometry_masked_out_collection is None:
2790
- # Convert the polygon to a mask
2791
- full_mask = ee.Image.constant(1)
2792
-
2793
- # Use paint to set pixels inside polygon as 0
2794
- area = full_mask.paint(polygon, 0)
2864
+ # Convert the polygon to a mask
2865
+ full_mask = ee.Image.constant(1)
2795
2866
 
2796
- # Update the mask of each image in the collection
2797
- masked_collection = self.collection.map(lambda img: img.updateMask(area))
2798
-
2799
- # Update the internal collection state
2800
- self._geometry_masked_out_collection = Sentinel2Collection(
2801
- collection=masked_collection
2802
- )
2867
+ # Use paint to set pixels inside polygon as 0
2868
+ area = full_mask.paint(polygon, 0)
2803
2869
 
2870
+ # Update the mask of each image in the collection
2871
+ masked_collection = self.collection.map(lambda img: img.updateMask(area)\
2872
+ .copyProperties(img).set('system:time_start', img.get('system:time_start')))
2804
2873
  # Return the updated object
2805
- return self._geometry_masked_out_collection
2874
+ return Sentinel2Collection(collection=masked_collection)
2806
2875
 
2807
2876
  def mask_halite(self, threshold):
2808
2877
  """
@@ -2977,6 +3046,9 @@ class Sentinel2Collection:
2977
3046
 
2978
3047
  if geometry is not None and not isinstance(geometry, ee.Geometry):
2979
3048
  raise ValueError(f'The chosen `geometry`: {geometry} is not a valid ee.Geometry object.')
3049
+
3050
+ native_projection = image_collection.first().select(target_band).projection()
3051
+
2980
3052
  # define the join, which will join all images newer than the current image
2981
3053
  # use system:time_start if the image does not have a Date_Filter property
2982
3054
  if join_method == 'system:time_start':
@@ -3032,7 +3104,7 @@ class Sentinel2Collection:
3032
3104
  # convert the image collection to an image of s_statistic values per pixel
3033
3105
  # where the s_statistic is the sum of partial s values
3034
3106
  # renaming the band as 's_statistic' for later usage
3035
- final_s_image = partial_s_col.sum().rename('s_statistic')
3107
+ final_s_image = partial_s_col.sum().rename('s_statistic').setDefaultProjection(native_projection)
3036
3108
 
3037
3109
 
3038
3110
  ########## PART 2 - VARIANCE and Z-SCORE ##########
@@ -3095,7 +3167,7 @@ class Sentinel2Collection:
3095
3167
  mask = ee.Image(1).clip(geometry)
3096
3168
  final_image = final_image.updateMask(mask)
3097
3169
 
3098
- return final_image
3170
+ return final_image.setDefaultProjection(native_projection)
3099
3171
 
3100
3172
  def sens_slope_trend(self, target_band=None, join_method='system:time_start', geometry=None):
3101
3173
  """
@@ -3130,6 +3202,8 @@ class Sentinel2Collection:
3130
3202
 
3131
3203
  if geometry is not None and not isinstance(geometry, ee.Geometry):
3132
3204
  raise ValueError(f'The chosen `geometry`: {geometry} is not a valid ee.Geometry object.')
3205
+
3206
+ native_projection = image_collection.first().select(target_band).projection()
3133
3207
 
3134
3208
  # Add Year Band (Time X-Axis)
3135
3209
  def add_year_band(image):
@@ -3158,7 +3232,7 @@ class Sentinel2Collection:
3158
3232
  mask = ee.Image(1).clip(geometry)
3159
3233
  slope_band = slope_band.updateMask(mask)
3160
3234
 
3161
- return slope_band
3235
+ return slope_band.setDefaultProjection(native_projection)
3162
3236
 
3163
3237
 
3164
3238
  def mask_via_band(self, band_to_mask, band_for_mask, threshold=-1, mask_above=True, add_band_to_original_image=False):
@@ -3302,7 +3376,7 @@ class Sentinel2Collection:
3302
3376
  new_col = self.collection.filter(ee.Filter.eq("Date_Filter", img_date))
3303
3377
  return new_col.first()
3304
3378
 
3305
- def CollectionStitch(self, img_col2):
3379
+ def collectionStitch(self, img_col2):
3306
3380
  """
3307
3381
  Function to mosaic two Sentinel2Collection objects which share image dates.
3308
3382
  Mosaics are only formed for dates where both image collections have images.
@@ -3356,8 +3430,15 @@ class Sentinel2Collection:
3356
3430
  # Return a Sentinel2Collection instance
3357
3431
  return Sentinel2Collection(collection=new_col)
3358
3432
 
3433
+ def CollectionStitch(self, img_col2):
3434
+ warnings.warn(
3435
+ "The `CollectionStitch` method is deprecated and will be removed in future versions. Please use the `collectionStitch` method instead.",
3436
+ DeprecationWarning,
3437
+ stacklevel=2)
3438
+ return self.collectionStitch(img_col2)
3439
+
3359
3440
  @property
3360
- def MosaicByDate(self):
3441
+ def mosaicByDateDepr(self):
3361
3442
  """
3362
3443
  Property attribute function to mosaic collection images that share the same date. The properties CLOUD_PIXEL_PERCENTAGE and NODATA_PIXEL_PERCENTAGE
3363
3444
  for each image are used to calculate an overall mean, which replaces the CLOUD_PIXEL_PERCENTAGE and NODATA_PIXEL_PERCENTAGE for each mosaiced image.
@@ -3423,6 +3504,77 @@ class Sentinel2Collection:
3423
3504
  self._MosaicByDate = col
3424
3505
 
3425
3506
  return self._MosaicByDate
3507
+
3508
+ @property
3509
+ def mosaicByDate(self):
3510
+ """
3511
+ Property attribute function to mosaic collection images that share the same date.
3512
+
3513
+ The property CLOUD_COVER for each image is used to calculate an overall mean,
3514
+ which replaces the CLOUD_COVER property for each mosaiced image.
3515
+ Server-side friendly.
3516
+
3517
+ NOTE: if images are removed from the collection from cloud filtering, you may have mosaics composed of only one image.
3518
+
3519
+ Returns:
3520
+ LandsatCollection: LandsatCollection image collection with mosaiced imagery and mean CLOUD_COVER as a property
3521
+ """
3522
+ if self._MosaicByDate is None:
3523
+ distinct_dates = self.collection.distinct("Date_Filter")
3524
+
3525
+ # Define a join to link images by Date_Filter
3526
+ filter_date = ee.Filter.equals(leftField="Date_Filter", rightField="Date_Filter")
3527
+ join = ee.Join.saveAll(matchesKey="date_matches")
3528
+
3529
+ # Apply the join
3530
+ # Primary: Distinct dates collection
3531
+ # Secondary: The full original collection
3532
+ joined_col = ee.ImageCollection(join.apply(distinct_dates, self.collection, filter_date))
3533
+
3534
+ # Define the mosaicking function
3535
+ def _mosaic_day(img):
3536
+ # Recover the list of images for this day
3537
+ daily_list = ee.List(img.get("date_matches"))
3538
+ daily_col = ee.ImageCollection.fromImages(daily_list)
3539
+
3540
+ # Create the mosaic
3541
+ mosaic = daily_col.mosaic().setDefaultProjection(img.select([0]).projection())
3542
+
3543
+ # Calculate means for Sentinel-2 specific props
3544
+ cloud_pct = daily_col.aggregate_mean("CLOUDY_PIXEL_PERCENTAGE")
3545
+ nodata_pct = daily_col.aggregate_mean("NODATA_PIXEL_PERCENTAGE")
3546
+
3547
+ # Properties to preserve from the representative image
3548
+ props_of_interest = [
3549
+ "SPACECRAFT_NAME",
3550
+ "SENSING_ORBIT_NUMBER",
3551
+ "SENSING_ORBIT_DIRECTION",
3552
+ "MISSION_ID",
3553
+ "PLATFORM_IDENTIFIER",
3554
+ "system:time_start",
3555
+ "Date_Filter"
3556
+ ]
3557
+
3558
+ # Return mosaic with properties set
3559
+ return mosaic.copyProperties(img, props_of_interest).set({
3560
+ "CLOUDY_PIXEL_PERCENTAGE": cloud_pct,
3561
+ "NODATA_PIXEL_PERCENTAGE": nodata_pct
3562
+ })
3563
+
3564
+ # 5. Map the function and wrap the result
3565
+ mosaiced_col = joined_col.map(_mosaic_day)
3566
+ self._MosaicByDate = Sentinel2Collection(collection=mosaiced_col)
3567
+
3568
+ # Convert the list of mosaics to an ImageCollection
3569
+ return self._MosaicByDate
3570
+
3571
+ @property
3572
+ def MosaicByDate(self):
3573
+ warnings.warn(
3574
+ "The `MosaicByDate` property is deprecated and will be removed in future versions. Please use the `mosaicByDate` property instead.",
3575
+ DeprecationWarning,
3576
+ stacklevel=2)
3577
+ return self.mosaicByDate
3426
3578
 
3427
3579
  @staticmethod
3428
3580
  def ee_to_df(
@@ -3642,200 +3794,197 @@ class Sentinel2Collection:
3642
3794
  lines,
3643
3795
  line_names,
3644
3796
  reducer="mean",
3645
- dist_interval= 10,
3797
+ dist_interval=30,
3646
3798
  n_segments=None,
3647
3799
  scale=10,
3648
3800
  processing_mode='aggregated',
3649
3801
  save_folder_path=None,
3650
3802
  sampling_method='line',
3651
- point_buffer_radius=5
3803
+ point_buffer_radius=15,
3804
+ batch_size=10
3652
3805
  ):
3653
3806
  """
3654
- Computes and returns pixel values along transects for each image in a collection.
3655
-
3656
- This iterative function generates time-series data along one or more lines, and
3657
- supports two different geometric sampling methods ('line' and 'buffered_point')
3658
- for maximum flexibility and performance.
3659
-
3660
- There are two processing modes available, aggregated and iterative:
3661
- - 'aggregated' (default; suggested): Fast, server-side processing. Fetches all results
3662
- in a single request. Highly recommended. Returns a dictionary of pandas DataFrames.
3663
- - 'iterative': Slower, client-side loop that processes one image at a time.
3664
- Kept for backward compatibility (effectively depreciated). Returns None and saves individual CSVs.
3665
- This method is not recommended unless absolutely necessary, as it is less efficient and may be subject to client-side timeouts.
3666
-
3807
+ Computes and returns pixel values along transects. Provide a list of ee.Geometry.LineString objects and corresponding names, and the function will compute the specified reducer value
3808
+ at regular intervals along each line for all images in the collection. Use `dist_interval` or `n_segments` to control sampling resolution. The user can choose between 'aggregated' mode (returns a dictionary of DataFrames) or 'iterative' mode (saves individual CSVs for each transect).
3809
+ Alter `sampling_method` to sample directly along the line or via buffered points along the line. Buffered points can help capture more representative pixel values in heterogeneous landscapes, and the buffer radius can be adjusted via `point_buffer_radius`.
3810
+
3667
3811
  Args:
3668
- lines (list): A list of one or more ee.Geometry.LineString objects that
3669
- define the transects.
3670
- line_names (list): A list of string names for each transect. The length
3671
- of this list must match the length of the `lines` list.
3672
- reducer (str, optional): The name of the ee.Reducer to apply at each
3673
- transect point (e.g., 'mean', 'median', 'first'). Defaults to 'mean'.
3674
- dist_interval (float, optional): The distance interval in meters for
3675
- sampling points along each transect. Will be overridden if `n_segments` is provided.
3676
- Defaults to 10. Recommended to increase this value when using the
3677
- 'line' processing method, or else you may get blank rows.
3678
- n_segments (int, optional): The number of equal-length segments to split
3679
- each transect line into for sampling. This parameter overrides `dist_interval`.
3680
- Defaults to None.
3681
- scale (int, optional): The nominal scale in meters for the reduction,
3682
- which should typically match the pixel resolution of the imagery.
3683
- Defaults to 10.
3684
- processing_mode (str, optional): The method for processing the collection.
3685
- - 'aggregated' (default): Fast, server-side processing. Fetches all
3686
- results in a single request. Highly recommended. Returns a dictionary
3687
- of pandas DataFrames.
3688
- - 'iterative': Slower, client-side loop that processes one image at a
3689
- time. Kept for backward compatibility. Returns None and saves
3690
- individual CSVs.
3691
- save_folder_path (str, optional): If provided, the function will save the
3692
- resulting transect data to CSV files. The behavior depends on the
3693
- `processing_mode`:
3694
- - In 'aggregated' mode, one CSV is saved for each transect,
3695
- containing all dates. (e.g., 'MyTransect_transects.csv').
3696
- - In 'iterative' mode, one CSV is saved for each date,
3697
- containing all transects. (e.g., '2022-06-15_transects.csv').
3698
- sampling_method (str, optional): The geometric method used for sampling.
3699
- - 'line' (default): Reduces all pixels intersecting each small line
3700
- segment. This can be unreliable and produce blank rows if
3701
- `dist_interval` is too small relative to the `scale`.
3702
- - 'buffered_point': Reduces all pixels within a buffer around the
3703
- midpoint of each line segment. This method is more robust and
3704
- reliably avoids blank rows, but may not reduce all pixels along a line segment.
3705
- point_buffer_radius (int, optional): The radius in meters for the buffer
3706
- when `sampling_method` is 'buffered_point'. Defaults to 5.
3812
+ lines (list): List of ee.Geometry.LineString objects.
3813
+ line_names (list): List of string names for each transect.
3814
+ reducer (str, optional): Reducer name. Defaults to 'mean'.
3815
+ dist_interval (float, optional): Distance interval in meters. Defaults to 30.
3816
+ n_segments (int, optional): Number of segments (overrides dist_interval).
3817
+ scale (int, optional): Scale in meters. Defaults to 10.
3818
+ processing_mode (str, optional): 'aggregated' or 'iterative'.
3819
+ save_folder_path (str, optional): Path to save CSVs.
3820
+ sampling_method (str, optional): 'line' or 'buffered_point'.
3821
+ point_buffer_radius (int, optional): Buffer radius if using 'buffered_point'.
3822
+ batch_size (int, optional): Images per request in 'aggregated' mode. Defaults to 10. Lower the value if you encounter a 'Too many aggregations' error.
3707
3823
 
3708
3824
  Returns:
3709
- dict or None:
3710
- - If `processing_mode` is 'aggregated', returns a dictionary where each
3711
- key is a transect name and each value is a pandas DataFrame. In the
3712
- DataFrame, the index is the distance along the transect and each
3713
- column represents an image date. Optionally saves CSV files if
3714
- `save_folder_path` is provided.
3715
- - If `processing_mode` is 'iterative', returns None as it saves
3716
- files directly.
3717
-
3718
- Raises:
3719
- ValueError: If `lines` and `line_names` have different lengths, or if
3720
- an unknown reducer or processing mode is specified.
3825
+ dict or None: Dictionary of DataFrames (aggregated) or None (iterative).
3721
3826
  """
3722
- # Validating inputs
3723
3827
  if len(lines) != len(line_names):
3724
3828
  raise ValueError("'lines' and 'line_names' must have the same number of elements.")
3725
- ### Current, server-side processing method ###
3829
+
3830
+ first_img = self.collection.first()
3831
+ bands = first_img.bandNames().getInfo()
3832
+ is_multiband = len(bands) > 1
3833
+
3834
+ # Setup robust dictionary for handling masked/zero values
3835
+ default_val = -9999
3836
+ dummy_dict = ee.Dictionary.fromLists(bands, ee.List.repeat(default_val, len(bands)))
3837
+
3838
+ if is_multiband:
3839
+ reducer_cols = [f"{b}_{reducer}" for b in bands]
3840
+ clean_names = bands
3841
+ rename_keys = bands
3842
+ rename_vals = reducer_cols
3843
+ else:
3844
+ reducer_cols = [reducer]
3845
+ clean_names = [bands[0]]
3846
+ rename_keys = bands
3847
+ rename_vals = reducer_cols
3848
+
3849
+ print("Pre-computing transect geometries from input LineString(s)...")
3850
+
3851
+ master_transect_fc = ee.FeatureCollection([])
3852
+ geom_error = 1.0
3853
+
3854
+ for i, line in enumerate(lines):
3855
+ line_name = line_names[i]
3856
+ length = line.length(geom_error)
3857
+
3858
+ eff_interval = length.divide(n_segments) if n_segments else dist_interval
3859
+
3860
+ distances = ee.List.sequence(0, length, eff_interval)
3861
+ cut_lines = line.cutLines(distances, geom_error).geometries()
3862
+
3863
+ def create_feature(l):
3864
+ geom = ee.Geometry(ee.List(l).get(0))
3865
+ dist = ee.Number(ee.List(l).get(1))
3866
+
3867
+ final_geom = ee.Algorithms.If(
3868
+ ee.String(sampling_method).equals('buffered_point'),
3869
+ geom.centroid(geom_error).buffer(point_buffer_radius),
3870
+ geom
3871
+ )
3872
+
3873
+ return ee.Feature(ee.Geometry(final_geom), {
3874
+ 'transect_name': line_name,
3875
+ 'distance': dist
3876
+ })
3877
+
3878
+ line_fc = ee.FeatureCollection(cut_lines.zip(distances).map(create_feature))
3879
+ master_transect_fc = master_transect_fc.merge(line_fc)
3880
+
3881
+ try:
3882
+ ee_reducer = getattr(ee.Reducer, reducer)()
3883
+ except AttributeError:
3884
+ raise ValueError(f"Unknown reducer: '{reducer}'.")
3885
+
3886
+ def process_image(image):
3887
+ date_val = image.get('Date_Filter')
3888
+
3889
+ # Map over points (Slower but Robust)
3890
+ def reduce_point(f):
3891
+ stats = image.reduceRegion(
3892
+ reducer=ee_reducer,
3893
+ geometry=f.geometry(),
3894
+ scale=scale,
3895
+ maxPixels=1e13
3896
+ )
3897
+ # Combine with defaults (preserves 0, handles masked)
3898
+ safe_stats = dummy_dict.combine(stats, overwrite=True)
3899
+ # Rename keys to match expected outputs (e.g. 'ndvi' -> 'ndvi_mean')
3900
+ final_stats = safe_stats.rename(rename_keys, rename_vals)
3901
+
3902
+ return f.set(final_stats).set({'image_date': date_val})
3903
+
3904
+ return master_transect_fc.map(reduce_point)
3905
+
3906
+ export_cols = ['transect_name', 'distance', 'image_date'] + reducer_cols
3907
+
3726
3908
  if processing_mode == 'aggregated':
3727
- # Validating reducer type
3728
- try:
3729
- ee_reducer = getattr(ee.Reducer, reducer)()
3730
- except AttributeError:
3731
- raise ValueError(f"Unknown reducer: '{reducer}'.")
3732
- ### Function to extract transects for a single image
3733
- def get_transects_for_image(image):
3734
- image_date = image.get('Date_Filter')
3735
- # Initialize an empty list to hold all transect FeatureCollections
3736
- all_transects_for_image = ee.List([])
3737
- # Looping through each line and processing
3738
- for i, line in enumerate(lines):
3739
- # Index line and name
3740
- line_name = line_names[i]
3741
- # Determine maxError based on image projection, used for geometry operations
3742
- maxError = image.projection().nominalScale().divide(5)
3743
- # Calculate effective distance interval
3744
- length = line.length(maxError) # using maxError here ensures consistency with cutLines
3745
- # Determine effective distance interval based on n_segments or dist_interval
3746
- effective_dist_interval = ee.Algorithms.If(
3747
- n_segments,
3748
- length.divide(n_segments),
3749
- dist_interval or 30 # Defaults to 30 if both are None
3750
- )
3751
- # Generate distances along the line(s) for segmentation
3752
- distances = ee.List.sequence(0, length, effective_dist_interval)
3753
- # Segmenting the line into smaller lines at the specified distances
3754
- cut_lines_geoms = line.cutLines(distances, maxError).geometries()
3755
- # Function to create features with distance attributes
3756
- # Adjusted to ensure consistent return types
3757
- def set_dist_attr(l):
3758
- # l is a list: [geometry, distance]
3759
- # Extracting geometry portion of line
3760
- geom_segment = ee.Geometry(ee.List(l).get(0))
3761
- # Extracting distance value for attribute
3762
- distance = ee.Number(ee.List(l).get(1))
3763
- ### Determine final geometry based on sampling method
3764
- # If the sampling method is 'buffered_point',
3765
- # create a buffered point feature at the centroid of each segment,
3766
- # otherwise create a line feature
3767
- final_feature = ee.Algorithms.If(
3768
- ee.String(sampling_method).equals('buffered_point'),
3769
- # True Case: Create the buffered point feature
3770
- ee.Feature(
3771
- geom_segment.centroid(maxError).buffer(point_buffer_radius),
3772
- {'distance': distance}
3773
- ),
3774
- # False Case: Create the line segment feature
3775
- ee.Feature(geom_segment, {'distance': distance})
3776
- )
3777
- # Return either the line segment feature or the buffered point feature
3778
- return final_feature
3779
- # Creating a FeatureCollection of the cut lines with distance attributes
3780
- # Using map to apply the set_dist_attr function to each cut line geometry
3781
- line_features = ee.FeatureCollection(cut_lines_geoms.zip(distances).map(set_dist_attr))
3782
- # Reducing the image over the line features to get transect values
3783
- transect_fc = image.reduceRegions(
3784
- collection=line_features, reducer=ee_reducer, scale=scale
3785
- )
3786
- # Adding image date and line name properties to each feature
3787
- def set_props(feature):
3788
- return feature.set({'image_date': image_date, 'transect_name': line_name})
3789
- # Append to the list of all transects for this image
3790
- all_transects_for_image = all_transects_for_image.add(transect_fc.map(set_props))
3791
- # Combine all transect FeatureCollections into a single FeatureCollection and flatten
3792
- # Flatten is used to merge the list of FeatureCollections into one
3793
- return ee.FeatureCollection(all_transects_for_image).flatten()
3794
- # Map the function over the entire image collection and flatten the results
3795
- results_fc = ee.FeatureCollection(self.collection.map(get_transects_for_image)).flatten()
3796
- # Convert the results to a pandas DataFrame
3797
- df = Sentinel2Collection.ee_to_df(results_fc, remove_geom=True)
3798
- # Check if the DataFrame is empty
3799
- if df.empty:
3800
- print("Warning: No transect data was generated.")
3909
+ collection_size = self.collection.size().getInfo()
3910
+ print(f"Starting batch process of {collection_size} images...")
3911
+
3912
+ dfs = []
3913
+ for i in range(0, collection_size, batch_size):
3914
+ print(f" Processing image {i} to {min(i + batch_size, collection_size)}...")
3915
+
3916
+ batch_col = ee.ImageCollection(self.collection.toList(batch_size, i))
3917
+ results_fc = batch_col.map(process_image).flatten()
3918
+
3919
+ # Dynamic Class Call for ee_to_df
3920
+ df_batch = self.__class__.ee_to_df(results_fc, columns=export_cols, remove_geom=True)
3921
+
3922
+ if not df_batch.empty:
3923
+ dfs.append(df_batch)
3924
+
3925
+ if not dfs:
3926
+ print("Warning: No transect data generated.")
3801
3927
  return {}
3802
- # Initialize dictionary to hold output DataFrames for each transect
3928
+
3929
+ df = pd.concat(dfs, ignore_index=True)
3930
+
3931
+ # Post-Process & Split
3803
3932
  output_dfs = {}
3804
- # Loop through each unique transect name and create a pivot table
3933
+ for col in reducer_cols:
3934
+ df[col] = pd.to_numeric(df[col], errors='coerce')
3935
+ df[col] = df[col].replace(-9999, np.nan)
3936
+
3805
3937
  for name in sorted(df['transect_name'].unique()):
3806
- transect_df = df[df['transect_name'] == name]
3807
- pivot_df = transect_df.pivot(index='distance', columns='image_date', values=reducer)
3808
- pivot_df.columns.name = 'Date'
3809
- output_dfs[name] = pivot_df
3810
- # Optionally save each transect DataFrame to CSV
3811
- if save_folder_path:
3812
- for transect_name, transect_df in output_dfs.items():
3813
- safe_filename = "".join(x for x in transect_name if x.isalnum() or x in "._-")
3814
- file_path = f"{save_folder_path}{safe_filename}_transects.csv"
3815
- transect_df.to_csv(file_path)
3816
- print(f"Saved transect data to {file_path}")
3817
-
3938
+ line_df = df[df['transect_name'] == name]
3939
+
3940
+ for raw_col, band_name in zip(reducer_cols, clean_names):
3941
+ try:
3942
+ # Safety drop for duplicates
3943
+ line_df_clean = line_df.drop_duplicates(subset=['distance', 'image_date'])
3944
+
3945
+ pivot = line_df_clean.pivot(index='distance', columns='image_date', values=raw_col)
3946
+ pivot.columns.name = 'Date'
3947
+ key = f"{name}_{band_name}"
3948
+ output_dfs[key] = pivot
3949
+
3950
+ if save_folder_path:
3951
+ safe_key = "".join(x for x in key if x.isalnum() or x in "._-")
3952
+ fname = f"{save_folder_path}{safe_key}_transects.csv"
3953
+ pivot.to_csv(fname)
3954
+ print(f"Saved: {fname}")
3955
+ except Exception as e:
3956
+ print(f"Skipping pivot for {name}/{band_name}: {e}")
3957
+
3818
3958
  return output_dfs
3819
3959
 
3820
- ### old, depreciated iterative client-side processing method ###
3821
3960
  elif processing_mode == 'iterative':
3822
3961
  if not save_folder_path:
3823
- raise ValueError("`save_folder_path` is required for 'iterative' processing mode.")
3962
+ raise ValueError("save_folder_path is required for iterative mode.")
3824
3963
 
3825
3964
  image_collection_dates = self.dates
3826
3965
  for i, date in enumerate(image_collection_dates):
3827
3966
  try:
3828
3967
  print(f"Processing image {i+1}/{len(image_collection_dates)}: {date}")
3829
- image = self.image_grab(i)
3830
- transects_df = Sentinel2Collection.transect(
3831
- image, lines, line_names, reducer, n_segments, dist_interval, to_pandas=True
3832
- )
3833
- transects_df.to_csv(f"{save_folder_path}{date}_transects.csv")
3834
- print(f"{date}_transects saved to csv")
3968
+ image_list = self.collection.toList(self.collection.size())
3969
+ image = ee.Image(image_list.get(i))
3970
+
3971
+ fc_result = process_image(image)
3972
+ df = self.__class__.ee_to_df(fc_result, columns=export_cols, remove_geom=True)
3973
+
3974
+ if not df.empty:
3975
+ for col in reducer_cols:
3976
+ df[col] = pd.to_numeric(df[col], errors='coerce')
3977
+ df[col] = df[col].replace(-9999, np.nan)
3978
+
3979
+ fname = f"{save_folder_path}{date}_transects.csv"
3980
+ df.to_csv(fname, index=False)
3981
+ print(f"Saved: {fname}")
3982
+ else:
3983
+ print(f"Skipping {date}: No data.")
3835
3984
  except Exception as e:
3836
- print(f"An error occurred while processing image {i+1}: {e}")
3985
+ print(f"Error processing {date}: {e}")
3837
3986
  else:
3838
- raise ValueError("`processing_mode` must be 'iterative' or 'aggregated'.")
3987
+ raise ValueError("processing_mode must be 'iterative' or 'aggregated'.")
3839
3988
 
3840
3989
  @staticmethod
3841
3990
  def extract_zonal_stats_from_buffer(
@@ -3939,7 +4088,8 @@ class Sentinel2Collection:
3939
4088
  buffer_size=1,
3940
4089
  tileScale=1,
3941
4090
  dates=None,
3942
- file_path=None
4091
+ file_path=None,
4092
+ unweighted=False
3943
4093
  ):
3944
4094
  """
3945
4095
  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.
@@ -3958,6 +4108,7 @@ class Sentinel2Collection:
3958
4108
  tileScale (int, optional): A scaling factor to reduce aggregation tile size. Defaults to 1.
3959
4109
  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.
3960
4110
  file_path (str, optional): File path to save the output CSV.
4111
+ unweighted (bool, optional): Whether to use an unweighted reducer. Defaults to False.
3961
4112
 
3962
4113
  Returns:
3963
4114
  pd.DataFrame or None: A pandas DataFrame with dates as the index and coordinate names
@@ -4064,6 +4215,9 @@ class Sentinel2Collection:
4064
4215
  reducer = getattr(ee.Reducer, reducer_type)()
4065
4216
  except AttributeError:
4066
4217
  raise ValueError(f"Unknown reducer_type: '{reducer_type}'.")
4218
+
4219
+ if unweighted:
4220
+ reducer = reducer.unweighted()
4067
4221
 
4068
4222
  # Define the function to map over the image collection
4069
4223
  def calculate_stats_for_image(image):
@@ -4125,6 +4279,394 @@ class Sentinel2Collection:
4125
4279
  print(f"Zonal stats saved to {file_path}.csv")
4126
4280
  return
4127
4281
  return pivot_df
4282
+
4283
+ def multiband_zonal_stats(
4284
+ self,
4285
+ geometry,
4286
+ bands,
4287
+ reducer_types,
4288
+ scale=30,
4289
+ geometry_name='geom',
4290
+ dates=None,
4291
+ include_area=False,
4292
+ file_path=None,
4293
+ unweighted=False
4294
+ ):
4295
+ """
4296
+ Calculates zonal statistics for multiple bands over a single geometry for each image in the collection.
4297
+ Allows for specifying different reducers for different bands. Optionally includes the geometry area.
4298
+
4299
+ Args:
4300
+ geometry (ee.Geometry or ee.Feature): The single geometry to calculate statistics for.
4301
+ bands (list of str): A list of band names to include in the analysis.
4302
+ reducer_types (str or list of str): A single reducer name (e.g., 'mean') to apply to all bands,
4303
+ or a list of reducer names matching the length of the 'bands' list to apply specific reducers
4304
+ to specific bands.
4305
+ scale (int, optional): The scale in meters for the reduction. Defaults to 30.
4306
+ geometry_name (str, optional): A name for the geometry, used in column naming. Defaults to 'geom'.
4307
+ dates (list of str, optional): A list of date strings ('YYYY-MM-DD') to filter the collection.
4308
+ Defaults to None (processes all images).
4309
+ include_area (bool, optional): If True, adds a column with the area of the geometry in square meters.
4310
+ Defaults to False.
4311
+ file_path (str, optional): If provided, saves the resulting DataFrame to a CSV file at this path.
4312
+ unweighted (bool, optional): Whether to use unweighted reducers. Defaults to False.
4313
+
4314
+ Returns:
4315
+ pd.DataFrame: A pandas DataFrame indexed by Date, with columns named as '{band}_{geometry_name}_{reducer}'.
4316
+ """
4317
+ # 1. Input Validation and Setup
4318
+ if not isinstance(geometry, (ee.Geometry, ee.Feature)):
4319
+ raise ValueError("The `geometry` argument must be an ee.Geometry or ee.Feature.")
4320
+
4321
+ region = geometry.geometry() if isinstance(geometry, ee.Feature) else geometry
4322
+
4323
+ if isinstance(bands, str):
4324
+ bands = [bands]
4325
+ if not isinstance(bands, list):
4326
+ raise ValueError("The `bands` argument must be a string or a list of strings.")
4327
+
4328
+ # Handle reducer_types (str vs list)
4329
+ if isinstance(reducer_types, str):
4330
+ reducers_list = [reducer_types] * len(bands)
4331
+ elif isinstance(reducer_types, list):
4332
+ if len(reducer_types) != len(bands):
4333
+ raise ValueError("If `reducer_types` is a list, it must have the same length as `bands`.")
4334
+ reducers_list = reducer_types
4335
+ else:
4336
+ raise ValueError("`reducer_types` must be a string or a list of strings.")
4337
+
4338
+ # 2. Filter Collection
4339
+ processing_col = self.collection
4340
+
4341
+ if dates:
4342
+ processing_col = processing_col.filter(ee.Filter.inList('Date_Filter', dates))
4343
+
4344
+ processing_col = processing_col.select(bands)
4345
+
4346
+ # 3. Pre-calculate Area (if requested)
4347
+ area_val = None
4348
+ area_col_name = f"{geometry_name}_area_m2"
4349
+ if include_area:
4350
+ # Calculate geodesic area in square meters with maxError of 1m
4351
+ area_val = region.area(1)
4352
+
4353
+ # 4. Define the Reduction Logic
4354
+ def calculate_multiband_stats(image):
4355
+ # Base feature with date property
4356
+ date_val = image.get('Date_Filter')
4357
+ feature = ee.Feature(None, {'Date': date_val})
4358
+
4359
+ # If requested, add the static area value to every feature
4360
+ if include_area:
4361
+ feature = feature.set(area_col_name, area_val)
4362
+
4363
+ unique_reducers = list(set(reducers_list))
4364
+
4365
+ # OPTIMIZED PATH: Single reducer type for all bands
4366
+ if len(unique_reducers) == 1:
4367
+ r_type = unique_reducers[0]
4368
+ try:
4369
+ reducer = getattr(ee.Reducer, r_type)()
4370
+ except AttributeError:
4371
+ reducer = ee.Reducer.mean()
4372
+
4373
+ if unweighted:
4374
+ reducer = reducer.unweighted()
4375
+
4376
+ stats = image.reduceRegion(
4377
+ reducer=reducer,
4378
+ geometry=region,
4379
+ scale=scale,
4380
+ maxPixels=1e13
4381
+ )
4382
+
4383
+ for band in bands:
4384
+ col_name = f"{band}_{geometry_name}_{r_type}"
4385
+ val = stats.get(band)
4386
+ feature = feature.set(col_name, val)
4387
+
4388
+ # ITERATIVE PATH: Different reducers for different bands
4389
+ else:
4390
+ for band, r_type in zip(bands, reducers_list):
4391
+ try:
4392
+ reducer = getattr(ee.Reducer, r_type)()
4393
+ except AttributeError:
4394
+ reducer = ee.Reducer.mean()
4395
+
4396
+ if unweighted:
4397
+ reducer = reducer.unweighted()
4398
+
4399
+ stats = image.select(band).reduceRegion(
4400
+ reducer=reducer,
4401
+ geometry=region,
4402
+ scale=scale,
4403
+ maxPixels=1e13
4404
+ )
4405
+
4406
+ val = stats.get(band)
4407
+ col_name = f"{band}_{geometry_name}_{r_type}"
4408
+ feature = feature.set(col_name, val)
4409
+
4410
+ return feature
4411
+
4412
+ # 5. Execute Server-Side Mapping (with explicit Cast)
4413
+ results_fc = ee.FeatureCollection(processing_col.map(calculate_multiband_stats))
4414
+
4415
+ # 6. Client-Side Conversion
4416
+ try:
4417
+ df = Sentinel2Collection.ee_to_df(results_fc, remove_geom=True)
4418
+ except Exception as e:
4419
+ raise RuntimeError(f"Failed to convert Earth Engine results to DataFrame. Error: {e}")
4420
+
4421
+ if df.empty:
4422
+ print("Warning: No results returned. Check if the geometry intersects the imagery or if dates are valid.")
4423
+ return pd.DataFrame()
4424
+
4425
+ # 7. Formatting & Reordering
4426
+ if 'Date' in df.columns:
4427
+ df['Date'] = pd.to_datetime(df['Date'])
4428
+ df = df.sort_values('Date').set_index('Date')
4429
+
4430
+ # Construct the expected column names in the exact order of the input lists
4431
+ expected_order = [f"{band}_{geometry_name}_{r_type}" for band, r_type in zip(bands, reducers_list)]
4432
+
4433
+ # If area was included, append it to the END of the list
4434
+ if include_area:
4435
+ expected_order.append(area_col_name)
4436
+
4437
+ # Reindex the DataFrame to match this order.
4438
+ existing_cols = [c for c in expected_order if c in df.columns]
4439
+ df = df[existing_cols]
4440
+
4441
+ # 8. Export (Optional)
4442
+ if file_path:
4443
+ if not file_path.lower().endswith('.csv'):
4444
+ file_path += '.csv'
4445
+ try:
4446
+ df.to_csv(file_path)
4447
+ print(f"Multiband zonal stats saved to {file_path}")
4448
+ except Exception as e:
4449
+ print(f"Error saving file to {file_path}: {e}")
4450
+
4451
+ return df
4452
+
4453
+ def sample(
4454
+ self,
4455
+ locations,
4456
+ band=None,
4457
+ scale=None,
4458
+ location_names=None,
4459
+ dates=None,
4460
+ file_path=None,
4461
+ tileScale=1
4462
+ ):
4463
+ """
4464
+ Extracts time-series pixel values for a list of locations.
4465
+
4466
+
4467
+ Args:
4468
+ locations (list, tuple, ee.Geometry, or ee.FeatureCollection): Input points.
4469
+ band (str, optional): The name of the band to sample. Defaults to the first band.
4470
+ scale (int, optional): Scale in meters. Defaults to 30 if None.
4471
+ location_names (list of str, optional): Custom names for locations.
4472
+ dates (list, optional): Date filter ['YYYY-MM-DD'].
4473
+ file_path (str, optional): CSV export path.
4474
+ tileScale (int, optional): Aggregation tile scale. Defaults to 1.
4475
+
4476
+ Returns:
4477
+ pd.DataFrame (or CSV if file_path is provided): DataFrame indexed by Date, columns by Location.
4478
+ """
4479
+ col = self.collection
4480
+ if dates:
4481
+ col = col.filter(ee.Filter.inList('Date_Filter', dates))
4482
+
4483
+ first_img = col.first()
4484
+ available_bands = first_img.bandNames().getInfo()
4485
+
4486
+ if band:
4487
+ if band not in available_bands:
4488
+ raise ValueError(f"Band '{band}' not found. Available: {available_bands}")
4489
+ target_band = band
4490
+ else:
4491
+ target_band = available_bands[0]
4492
+
4493
+ processing_col = col.select([target_band])
4494
+
4495
+ def set_name(f):
4496
+ name = ee.Algorithms.If(
4497
+ f.get('geo_name'), f.get('geo_name'),
4498
+ ee.Algorithms.If(f.get('name'), f.get('name'),
4499
+ ee.Algorithms.If(f.get('system:index'), f.get('system:index'), 'unnamed'))
4500
+ )
4501
+ return f.set('geo_name', name)
4502
+
4503
+ if isinstance(locations, (ee.FeatureCollection, ee.Feature)):
4504
+ features = ee.FeatureCollection(locations)
4505
+ elif isinstance(locations, ee.Geometry):
4506
+ lbl = location_names[0] if (location_names and location_names[0]) else 'Point_1'
4507
+ features = ee.FeatureCollection([ee.Feature(locations).set('geo_name', lbl)])
4508
+ elif isinstance(locations, tuple) and len(locations) == 2:
4509
+ lbl = location_names[0] if location_names else 'Location_1'
4510
+ features = ee.FeatureCollection([ee.Feature(ee.Geometry.Point(locations), {'geo_name': lbl})])
4511
+ elif isinstance(locations, list):
4512
+ if all(isinstance(i, tuple) for i in locations):
4513
+ names = location_names if location_names else [f"Loc_{i+1}" for i in range(len(locations))]
4514
+ features = ee.FeatureCollection([
4515
+ ee.Feature(ee.Geometry.Point(p), {'geo_name': str(n)}) for p, n in zip(locations, names)
4516
+ ])
4517
+ elif all(isinstance(i, ee.Geometry) for i in locations):
4518
+ names = location_names if location_names else [f"Geom_{i+1}" for i in range(len(locations))]
4519
+ features = ee.FeatureCollection([
4520
+ ee.Feature(g, {'geo_name': str(n)}) for g, n in zip(locations, names)
4521
+ ])
4522
+ else:
4523
+ raise ValueError("List must contain (lon, lat) tuples or ee.Geometry objects.")
4524
+ else:
4525
+ raise TypeError("Invalid locations input.")
4526
+
4527
+ features = features.map(set_name)
4528
+
4529
+
4530
+ def sample_image(img):
4531
+ date = img.get('Date_Filter')
4532
+ use_scale = scale if scale is not None else 30
4533
+
4534
+
4535
+ default_dict = ee.Dictionary({target_band: -9999})
4536
+
4537
+ def extract_point(f):
4538
+ stats = img.reduceRegion(
4539
+ reducer=ee.Reducer.first(),
4540
+ geometry=f.geometry(),
4541
+ scale=use_scale,
4542
+ tileScale=tileScale
4543
+ )
4544
+
4545
+ # Combine dictionaries.
4546
+ # If stats has 'target_band' (even if 0), it overwrites -9999.
4547
+ # If stats is empty (masked), -9999 remains.
4548
+ safe_stats = default_dict.combine(stats, overwrite=True)
4549
+ val = safe_stats.get(target_band)
4550
+
4551
+ return f.set({
4552
+ target_band: val,
4553
+ 'image_date': date
4554
+ })
4555
+
4556
+ return features.map(extract_point)
4557
+
4558
+ # Flatten the results
4559
+ flat_results = processing_col.map(sample_image).flatten()
4560
+
4561
+ df = Sentinel2Collection.ee_to_df(
4562
+ flat_results,
4563
+ columns=['image_date', 'geo_name', target_band],
4564
+ remove_geom=True
4565
+ )
4566
+
4567
+ if df.empty:
4568
+ print("Warning: No data returned.")
4569
+ return pd.DataFrame()
4570
+
4571
+ # 6. Clean and Pivot
4572
+ df[target_band] = pd.to_numeric(df[target_band], errors='coerce')
4573
+
4574
+ # Filter out ONLY the sentinel value (-9999), preserving 0.
4575
+ df = df[df[target_band] != -9999]
4576
+
4577
+ if df.empty:
4578
+ print(f"Warning: All data points were masked (NoData) for band '{target_band}'.")
4579
+ return pd.DataFrame()
4580
+
4581
+ pivot_df = df.pivot(index='image_date', columns='geo_name', values=target_band)
4582
+ pivot_df.index.name = 'Date'
4583
+ pivot_df.columns.name = None
4584
+ pivot_df = pivot_df.reset_index()
4585
+
4586
+ if file_path:
4587
+ if not file_path.lower().endswith('.csv'):
4588
+ file_path += '.csv'
4589
+ pivot_df.to_csv(file_path, index=False)
4590
+ print(f"Sampled data saved to {file_path}")
4591
+ return None
4592
+
4593
+ return pivot_df
4594
+
4595
+ def multiband_sample(
4596
+ self,
4597
+ location,
4598
+ scale=30,
4599
+ file_path=None
4600
+ ):
4601
+ """
4602
+ Extracts ALL band values for a SINGLE location across the entire collection.
4603
+
4604
+ Args:
4605
+ location (tuple or ee.Geometry): A single (lon, lat) tuple OR ee.Geometry.
4606
+ scale (int, optional): Scale in meters. Defaults to 30.
4607
+ file_path (str, optional): Path to save CSV.
4608
+
4609
+ Returns:
4610
+ pd.DataFrame: DataFrame indexed by Date, with columns for each Band.
4611
+ """
4612
+ if isinstance(location, tuple) and len(location) == 2:
4613
+ geom = ee.Geometry.Point(location)
4614
+ elif isinstance(location, ee.Geometry):
4615
+ geom = location
4616
+ else:
4617
+ raise ValueError("Location must be a single (lon, lat) tuple or ee.Geometry.")
4618
+
4619
+ first_img = self.collection.first()
4620
+ band_names = first_img.bandNames()
4621
+
4622
+ # Create a dictionary of {band_name: -9999}
4623
+ # fill missing values so the Feature structure is consistent
4624
+ dummy_values = ee.List.repeat(-9999, band_names.length())
4625
+ default_dict = ee.Dictionary.fromLists(band_names, dummy_values)
4626
+
4627
+ def get_all_bands(img):
4628
+ date = img.get('Date_Filter')
4629
+
4630
+ # reduceRegion returns a Dictionary.
4631
+ # If a pixel is masked, that band key is missing from 'stats'.
4632
+ stats = img.reduceRegion(
4633
+ reducer=ee.Reducer.first(),
4634
+ geometry=geom,
4635
+ scale=scale,
4636
+ maxPixels=1e13
4637
+ )
4638
+
4639
+ # Combine stats with defaults.
4640
+ # overwrite=True means real data (stats) overwrites the -9999 defaults.
4641
+ complete_stats = default_dict.combine(stats, overwrite=True)
4642
+
4643
+ return ee.Feature(None, complete_stats).set('Date', date)
4644
+
4645
+ fc = ee.FeatureCollection(self.collection.map(get_all_bands))
4646
+
4647
+ df = Sentinel2Collection.ee_to_df(fc, remove_geom=True)
4648
+
4649
+ if df.empty:
4650
+ print("Warning: No data found.")
4651
+ return pd.DataFrame()
4652
+
4653
+ # 6. Cleanup
4654
+ if 'Date' in df.columns:
4655
+ df['Date'] = pd.to_datetime(df['Date'])
4656
+ df = df.set_index('Date').sort_index()
4657
+
4658
+ # Replace our sentinel -9999 with proper NaNs
4659
+ df = df.replace(-9999, np.nan)
4660
+
4661
+ # 7. Export
4662
+ if file_path:
4663
+ if not file_path.lower().endswith('.csv'):
4664
+ file_path += '.csv'
4665
+ df.to_csv(file_path)
4666
+ print(f"Multiband sample saved to {file_path}")
4667
+ return None
4668
+
4669
+ return df
4128
4670
 
4129
4671
  def export_to_asset_collection(
4130
4672
  self,
@@ -4135,7 +4677,8 @@ class Sentinel2Collection:
4135
4677
  filename_prefix="",
4136
4678
  crs=None,
4137
4679
  max_pixels=int(1e13),
4138
- description_prefix="export"
4680
+ description_prefix="export",
4681
+ overwrite=False
4139
4682
  ):
4140
4683
  """
4141
4684
  Exports an image collection to a Google Earth Engine asset collection. The asset collection will be created if it does not already exist,
@@ -4150,6 +4693,7 @@ class Sentinel2Collection:
4150
4693
  crs (str, optional): The coordinate reference system. Defaults to None, which will use the image's CRS.
4151
4694
  max_pixels (int, optional): The maximum number of pixels. Defaults to int(1e13).
4152
4695
  description_prefix (str, optional): The description prefix. Defaults to "export".
4696
+ overwrite (bool, optional): Whether to overwrite existing assets. Defaults to False.
4153
4697
 
4154
4698
  Returns:
4155
4699
  None: (queues export tasks)
@@ -4167,6 +4711,14 @@ class Sentinel2Collection:
4167
4711
  asset_id = asset_collection_path + "/" + filename_prefix + date_str
4168
4712
  desc = description_prefix + "_" + filename_prefix + date_str
4169
4713
 
4714
+ if overwrite:
4715
+ try:
4716
+ ee.data.deleteAsset(asset_id)
4717
+ print(f"Overwriting: Deleted existing asset {asset_id}")
4718
+ except ee.EEException:
4719
+ # Asset does not exist, so nothing to delete. Proceed safely.
4720
+ pass
4721
+
4170
4722
  params = {
4171
4723
  'image': img,
4172
4724
  'description': desc,