RadGEEToolbox 1.7.0__py3-none-any.whl → 1.7.1__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.
@@ -155,6 +155,10 @@ class LandsatCollection:
155
155
  self._geometry_masked_out_collection = None
156
156
  self._median = None
157
157
  self._monthly_median = None
158
+ self._monthly_mean = None
159
+ self._monthly_max = None
160
+ self._monthly_min = None
161
+ self._monthly_sum = None
158
162
  self._mean = None
159
163
  self._max = None
160
164
  self._min = None
@@ -705,7 +709,7 @@ class LandsatCollection:
705
709
  return nbr
706
710
 
707
711
  @staticmethod
708
- def anomaly_fn(image, geometry, band_name=None, anomaly_band_name=None, replace=True):
712
+ def anomaly_fn(image, geometry, band_name=None, anomaly_band_name=None, replace=True, scale=30):
709
713
  """
710
714
  Calculates the anomaly of a singleband image compared to the mean of the singleband image.
711
715
 
@@ -721,6 +725,7 @@ class LandsatCollection:
721
725
  band_name (str, optional): A string representing the band name to be used for the output anomaly image. If not provided, the band name of the first band of the input image will be used.
722
726
  anomaly_band_name (str, optional): A string representing the band name to be used for the output anomaly image. If not provided, the band name of the first band of the input image will be used.
723
727
  replace (bool, optional): A boolean indicating whether to replace the original band with the anomaly band in the output image. If True, the output image will contain only the anomaly band. If False, the output image will contain both the original band and the anomaly band. Default is True.
728
+ scale (int, optional): The scale in meters to use for the reduction operation. Default is 30 meters.
724
729
 
725
730
  Returns:
726
731
  ee.Image: An ee.Image where each band represents the anomaly (deviation from
@@ -737,12 +742,16 @@ class LandsatCollection:
737
742
  mean_image = image_to_process.reduceRegion(
738
743
  reducer=ee.Reducer.mean(),
739
744
  geometry=geometry,
740
- scale=30,
745
+ scale=scale,
741
746
  maxPixels=1e13
742
747
  ).toImage()
743
748
 
744
749
  # Compute the anomaly by subtracting the mean image from the input image.
745
- anomaly_image = image_to_process.subtract(mean_image)
750
+ if scale == 30:
751
+ anomaly_image = image_to_process.subtract(mean_image)
752
+ else:
753
+ anomaly_image = image_to_process.reproject(crs=image_to_process.projection(), scale=scale).subtract(mean_image)
754
+
746
755
  if anomaly_band_name is None:
747
756
  if band_name:
748
757
  anomaly_image = anomaly_image.rename(band_name)
@@ -753,9 +762,9 @@ class LandsatCollection:
753
762
  anomaly_image = anomaly_image.rename(anomaly_band_name)
754
763
  # return anomaly_image
755
764
  if replace:
756
- return anomaly_image.copyProperties(image)
765
+ return anomaly_image.copyProperties(image).set('system:time_start', image.get('system:time_start'))
757
766
  else:
758
- return image.addBands(anomaly_image, overwrite=True)
767
+ return image.addBands(anomaly_image, overwrite=True).copyProperties(image)
759
768
 
760
769
  @staticmethod
761
770
  def MaskWaterLandsat(image):
@@ -1092,7 +1101,7 @@ class LandsatCollection:
1092
1101
  "downwelling": image.select("downwelling"),
1093
1102
  },
1094
1103
  ).rename("LST")
1095
- return image.addBands(LST).copyProperties(image) # Outputs temperature in C
1104
+ return image.addBands(LST).copyProperties(image).set('system:time_start', image.get('system:time_start')) # Outputs temperature in C
1096
1105
 
1097
1106
  @staticmethod
1098
1107
  def C_to_F_fn(LST_image):
@@ -1114,7 +1123,7 @@ class LandsatCollection:
1114
1123
 
1115
1124
  # Preserve original properties from the input image.
1116
1125
  # return fahrenheit_image.rename('LST_F').copyProperties(LST_image)
1117
- return LST_image.addBands(fahrenheit_image.rename('LST_F'), overwrite=True).copyProperties(LST_image)
1126
+ return LST_image.addBands(fahrenheit_image.rename('LST_F'), overwrite=True).copyProperties(LST_image).set('system:time_start', LST_image.get('system:time_start'))
1118
1127
 
1119
1128
  @staticmethod
1120
1129
  def band_rename_fn(image, current_band_name, new_band_name):
@@ -1214,8 +1223,7 @@ class LandsatCollection:
1214
1223
  return final_image
1215
1224
 
1216
1225
  def PixelAreaSumCollection(
1217
- self, band_name, geometry, threshold=-1, scale=30, maxPixels=1e12, output_type='ImageCollection', area_data_export_path=None
1218
- ):
1226
+ self, band_name, geometry, threshold=-1, scale=30, maxPixels=1e12, output_type='ImageCollection', area_data_export_path=None):
1219
1227
  """
1220
1228
  Calculates the geodesic summation of area for pixels of interest (above a specific threshold)
1221
1229
  within a geometry and stores the value as an image property (matching name of chosen band) for an entire
@@ -1225,11 +1233,11 @@ class LandsatCollection:
1225
1233
 
1226
1234
  Args:
1227
1235
  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.
1228
- geometry (ee.Geometry): ee.Geometry object denoting area to clip to for area calculation
1236
+ geometry (ee.Geometry): ee.Geometry object denoting area to clip to for area calculation.
1229
1237
  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.
1230
- scale (int): integer scale of image resolution (meters) (defaults to 30)
1231
- maxPixels (int): integer denoting maximum number of pixels for calculations
1232
- output_type (str): 'ImageCollection' to return an ee.ImageCollection, 'LandsatCollection' to return a LandsatCollection object (defaults to 'ImageCollection')
1238
+ scale (int): integer scale of image resolution (meters) (defaults to 30).
1239
+ maxPixels (int): integer denoting maximum number of pixels for calculations.
1240
+ output_type (str): 'ImageCollection' or 'ee.ImageCollection' to return an ee.ImageCollection, 'LandsatCollection' to return a LandsatCollection object, or 'DataFrame', 'Pandas', 'pd', 'dataframe', 'df' to return a pandas DataFrame (defaults to 'ImageCollection').
1233
1241
  area_data_export_path (str, optional): If provided, the function will save the resulting area data to a CSV file at the specified path.
1234
1242
 
1235
1243
  Returns:
@@ -1254,15 +1262,65 @@ class LandsatCollection:
1254
1262
 
1255
1263
  # If an export path is provided, the area data will be exported to a CSV file
1256
1264
  if area_data_export_path:
1257
- LandsatCollection(collection=self._PixelAreaSumCollection).ExportProperties(property_names=band_name, file_path=area_data_export_path+'.csv')
1265
+ LandsatCollection(collection=self._PixelAreaSumCollection).ExportProperties(property_names=[band_name], file_path=area_data_export_path+'.csv')
1258
1266
 
1259
1267
  # Returning the result in the desired format based on output_type argument or raising an error for invalid input
1260
- if output_type == 'ImageCollection':
1268
+ if output_type == 'ImageCollection' or output_type == 'ee.ImageCollection':
1261
1269
  return self._PixelAreaSumCollection
1262
1270
  elif output_type == 'LandsatCollection':
1263
1271
  return LandsatCollection(collection=self._PixelAreaSumCollection)
1272
+ elif output_type == 'DataFrame' or output_type == 'Pandas' or output_type == 'pd' or output_type == 'dataframe' or output_type == 'df':
1273
+ return LandsatCollection(collection=self._PixelAreaSumCollection).ExportProperties(property_names=[band_name])
1264
1274
  else:
1265
- raise ValueError("output_type must be 'ImageCollection' or 'LandsatCollection'")
1275
+ raise ValueError("Incorrect `output_type`. The `output_type` argument must be one of the following: 'ImageCollection', 'ee.ImageCollection', 'LandsatCollection', 'DataFrame', 'Pandas', 'pd', 'dataframe', or 'df'.")
1276
+
1277
+ @staticmethod
1278
+ def add_month_property_fn(image):
1279
+ """
1280
+ Adds a numeric 'month' property to the image based on its date.
1281
+
1282
+ Args:
1283
+ image (ee.Image): Input image.
1284
+
1285
+ Returns:
1286
+ ee.Image: Image with the 'month' property added.
1287
+ """
1288
+ return image.set('month', image.date().get('month'))
1289
+
1290
+ @property
1291
+ def add_month_property(self):
1292
+ """
1293
+ Adds a numeric 'month' property to each image in the collection.
1294
+
1295
+ Returns:
1296
+ LandsatCollection: A LandsatCollection image collection with the 'month' property added to each image.
1297
+ """
1298
+ col = self.collection.map(LandsatCollection.add_month_property_fn)
1299
+ return LandsatCollection(collection=col)
1300
+
1301
+ def remove_duplicate_dates(self, sort_by='system:time_start', ascending=True):
1302
+ """
1303
+ Removes duplicate images that share the same date, keeping only the first one encountered.
1304
+ Useful for handling duplicate acquisitions or overlapping path/rows.
1305
+
1306
+ Args:
1307
+ sort_by (str): Property to sort by before filtering distinct dates. Options: 'system:time_start' or 'CLOUD_COVER'.
1308
+ Defaults to 'system:time_start'.
1309
+ Recommended to use 'CLOUD_COVER' (ascending=True) to keep the clearest image.
1310
+ ascending (bool): Sort order. Defaults to True.
1311
+
1312
+ Returns:
1313
+ LandsatCollection: A new LandsatCollection object with distinct dates.
1314
+ """
1315
+ if sort_by not in ['system:time_start', 'CLOUD_COVER']:
1316
+ raise ValueError(f"The provided `sort_by` argument is invalid: {sort_by}. The `sort_by` argument must be either 'system:time_start' or 'CLOUD_COVER'.")
1317
+ # Sort the collection to ensure the "best" image comes first (e.g. least cloudy)
1318
+ sorted_col = self.collection.sort(sort_by, ascending)
1319
+
1320
+ # distinct() retains the first image for each unique value of the specified property
1321
+ distinct_col = sorted_col.distinct('Date_Filter')
1322
+
1323
+ return LandsatCollection(collection=distinct_col)
1266
1324
 
1267
1325
  def combine(self, other):
1268
1326
  """
@@ -1699,6 +1757,338 @@ class LandsatCollection:
1699
1757
  pass
1700
1758
 
1701
1759
  return self._monthly_median
1760
+
1761
+ @property
1762
+ def monthly_mean_collection(self):
1763
+ """Creates a monthly mean composite from a LandsatCollection image collection.
1764
+
1765
+ This function computes the mean for each
1766
+ month within the collection's date range, for each band in the collection. It automatically handles the full
1767
+ temporal extent of the input collection.
1768
+
1769
+ The resulting images have a 'system:time_start' property set to the
1770
+ first day of each month and an 'image_count' property indicating how
1771
+ many images were used in the composite. Months with no images are
1772
+ automatically excluded from the final collection.
1773
+
1774
+ NOTE: the day of month for the 'system:time_start' property is set to the earliest date of the first month observed and may not be the first day of the month.
1775
+
1776
+ Returns:
1777
+ LandsatCollection: A new LandsatCollection object with monthly mean composites.
1778
+ """
1779
+ if self._monthly_mean is None:
1780
+ collection = self.collection
1781
+ target_proj = collection.first().projection()
1782
+ # Get the start and end dates of the entire collection.
1783
+ date_range = collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
1784
+ original_start_date = ee.Date(date_range.get('min'))
1785
+ end_date = ee.Date(date_range.get('max'))
1786
+
1787
+ start_year = original_start_date.get('year')
1788
+ start_month = original_start_date.get('month')
1789
+ start_date = ee.Date.fromYMD(start_year, start_month, 1)
1790
+
1791
+ # Calculate the total number of months in the date range.
1792
+ # The .round() is important for ensuring we get an integer.
1793
+ num_months = end_date.difference(start_date, 'month').round()
1794
+
1795
+ # Generate a list of starting dates for each month.
1796
+ # This uses a sequence and advances the start date by 'i' months.
1797
+ def get_month_start(i):
1798
+ return start_date.advance(i, 'month')
1799
+
1800
+ month_starts = ee.List.sequence(0, num_months).map(get_month_start)
1801
+
1802
+ # Define a function to map over the list of month start dates.
1803
+ def create_monthly_composite(date):
1804
+ # Cast the input to an ee.Date object.
1805
+ start_of_month = ee.Date(date)
1806
+ # The end date is exclusive, so we advance by 1 month.
1807
+ end_of_month = start_of_month.advance(1, 'month')
1808
+
1809
+ # Filter the original collection to get images for the current month.
1810
+ monthly_subset = collection.filterDate(start_of_month, end_of_month)
1811
+
1812
+ # Count the number of images in the monthly subset.
1813
+ image_count = monthly_subset.size()
1814
+
1815
+ # Compute the mean. This is robust to outliers like clouds.
1816
+ monthly_mean = monthly_subset.mean()
1817
+
1818
+ # Set essential properties on the resulting composite image.
1819
+ # The timestamp is crucial for time-series analysis and charting.
1820
+ # The image_count is useful metadata for quality assessment.
1821
+ return monthly_mean.set({
1822
+ 'system:time_start': start_of_month.millis(),
1823
+ 'month': start_of_month.get('month'),
1824
+ 'year': start_of_month.get('year'),
1825
+ 'Date_Filter': start_of_month.format('YYYY-MM-dd'),
1826
+ 'image_count': image_count
1827
+ }).reproject(target_proj)
1828
+
1829
+ # Map the composite function over the list of month start dates.
1830
+ monthly_composites_list = month_starts.map(create_monthly_composite)
1831
+
1832
+ # Convert the list of images into an ee.ImageCollection.
1833
+ monthly_collection = ee.ImageCollection.fromImages(monthly_composites_list)
1834
+
1835
+ # Filter out any composites that were created from zero images.
1836
+ # This prevents empty/masked images from being in the final collection.
1837
+ final_collection = LandsatCollection(collection=monthly_collection.filter(ee.Filter.gt('image_count', 0)))
1838
+ self._monthly_mean = final_collection
1839
+ else:
1840
+ pass
1841
+
1842
+ return self._monthly_mean
1843
+
1844
+ @property
1845
+ def monthly_sum_collection(self):
1846
+ """Creates a monthly sum composite from a LandsatCollection image collection.
1847
+
1848
+ This function computes the sum for each
1849
+ month within the collection's date range, for each band in the collection. It automatically handles the full
1850
+ temporal extent of the input collection.
1851
+
1852
+ The resulting images have a 'system:time_start' property set to the
1853
+ first day of each month and an 'image_count' property indicating how
1854
+ many images were used in the composite. Months with no images are
1855
+ automatically excluded from the final collection.
1856
+
1857
+ NOTE: the day of month for the 'system:time_start' property is set to the earliest date of the first month observed and may not be the first day of the month.
1858
+
1859
+ Returns:
1860
+ LandsatCollection: A new LandsatCollection object with monthly sum composites.
1861
+ """
1862
+ if self._monthly_sum is None:
1863
+ collection = self.collection
1864
+ target_proj = collection.first().projection()
1865
+ # Get the start and end dates of the entire collection.
1866
+ date_range = collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
1867
+ original_start_date = ee.Date(date_range.get('min'))
1868
+ end_date = ee.Date(date_range.get('max'))
1869
+
1870
+ start_year = original_start_date.get('year')
1871
+ start_month = original_start_date.get('month')
1872
+ start_date = ee.Date.fromYMD(start_year, start_month, 1)
1873
+
1874
+ # Calculate the total number of months in the date range.
1875
+ # The .round() is important for ensuring we get an integer.
1876
+ num_months = end_date.difference(start_date, 'month').round()
1877
+
1878
+ # Generate a list of starting dates for each month.
1879
+ # This uses a sequence and advances the start date by 'i' months.
1880
+ def get_month_start(i):
1881
+ return start_date.advance(i, 'month')
1882
+
1883
+ month_starts = ee.List.sequence(0, num_months).map(get_month_start)
1884
+
1885
+ # Define a function to map over the list of month start dates.
1886
+ def create_monthly_composite(date):
1887
+ # Cast the input to an ee.Date object.
1888
+ start_of_month = ee.Date(date)
1889
+ # The end date is exclusive, so we advance by 1 month.
1890
+ end_of_month = start_of_month.advance(1, 'month')
1891
+
1892
+ # Filter the original collection to get images for the current month.
1893
+ monthly_subset = collection.filterDate(start_of_month, end_of_month)
1894
+
1895
+ # Count the number of images in the monthly subset.
1896
+ image_count = monthly_subset.size()
1897
+
1898
+ # Compute the sum. This is robust to outliers like clouds.
1899
+ monthly_sum = monthly_subset.sum()
1900
+
1901
+ # Set essential properties on the resulting composite image.
1902
+ # The timestamp is crucial for time-series analysis and charting.
1903
+ # The image_count is useful metadata for quality assessment.
1904
+ return monthly_sum.set({
1905
+ 'system:time_start': start_of_month.millis(),
1906
+ 'month': start_of_month.get('month'),
1907
+ 'year': start_of_month.get('year'),
1908
+ 'Date_Filter': start_of_month.format('YYYY-MM-dd'),
1909
+ 'image_count': image_count
1910
+ }).reproject(target_proj)
1911
+
1912
+ # Map the composite function over the list of month start dates.
1913
+ monthly_composites_list = month_starts.map(create_monthly_composite)
1914
+
1915
+ # Convert the list of images into an ee.ImageCollection.
1916
+ monthly_collection = ee.ImageCollection.fromImages(monthly_composites_list)
1917
+
1918
+ # Filter out any composites that were created from zero images.
1919
+ # This prevents empty/masked images from being in the final collection.
1920
+ final_collection = LandsatCollection(collection=monthly_collection.filter(ee.Filter.gt('image_count', 0)))
1921
+ self._monthly_sum = final_collection
1922
+ else:
1923
+ pass
1924
+
1925
+ return self._monthly_sum
1926
+
1927
+ @property
1928
+ def monthly_max_collection(self):
1929
+ """Creates a monthly max composite from a LandsatCollection image collection.
1930
+
1931
+ This function computes the max for each
1932
+ month within the collection's date range, for each band in the collection. It automatically handles the full
1933
+ temporal extent of the input collection.
1934
+
1935
+ The resulting images have a 'system:time_start' property set to the
1936
+ first day of each month and an 'image_count' property indicating how
1937
+ many images were used in the composite. Months with no images are
1938
+ automatically excluded from the final collection.
1939
+
1940
+ NOTE: the day of month for the 'system:time_start' property is set to the earliest date of the first month observed and may not be the first day of the month.
1941
+
1942
+ Returns:
1943
+ LandsatCollection: A new LandsatCollection object with monthly max composites.
1944
+ """
1945
+ if self._monthly_max is None:
1946
+ collection = self.collection
1947
+ target_proj = collection.first().projection()
1948
+ # Get the start and end dates of the entire collection.
1949
+ date_range = collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
1950
+ original_start_date = ee.Date(date_range.get('min'))
1951
+ end_date = ee.Date(date_range.get('max'))
1952
+
1953
+ start_year = original_start_date.get('year')
1954
+ start_month = original_start_date.get('month')
1955
+ start_date = ee.Date.fromYMD(start_year, start_month, 1)
1956
+
1957
+ # Calculate the total number of months in the date range.
1958
+ # The .round() is important for ensuring we get an integer.
1959
+ num_months = end_date.difference(start_date, 'month').round()
1960
+
1961
+ # Generate a list of starting dates for each month.
1962
+ # This uses a sequence and advances the start date by 'i' months.
1963
+ def get_month_start(i):
1964
+ return start_date.advance(i, 'month')
1965
+
1966
+ month_starts = ee.List.sequence(0, num_months).map(get_month_start)
1967
+
1968
+ # Define a function to map over the list of month start dates.
1969
+ def create_monthly_composite(date):
1970
+ # Cast the input to an ee.Date object.
1971
+ start_of_month = ee.Date(date)
1972
+ # The end date is exclusive, so we advance by 1 month.
1973
+ end_of_month = start_of_month.advance(1, 'month')
1974
+
1975
+ # Filter the original collection to get images for the current month.
1976
+ monthly_subset = collection.filterDate(start_of_month, end_of_month)
1977
+
1978
+ # Count the number of images in the monthly subset.
1979
+ image_count = monthly_subset.size()
1980
+
1981
+ # Compute the max. This is robust to outliers like clouds.
1982
+ monthly_max = monthly_subset.max()
1983
+
1984
+ # Set essential properties on the resulting composite image.
1985
+ # The timestamp is crucial for time-series analysis and charting.
1986
+ # The image_count is useful metadata for quality assessment.
1987
+ return monthly_max.set({
1988
+ 'system:time_start': start_of_month.millis(),
1989
+ 'month': start_of_month.get('month'),
1990
+ 'year': start_of_month.get('year'),
1991
+ 'Date_Filter': start_of_month.format('YYYY-MM-dd'),
1992
+ 'image_count': image_count
1993
+ }).reproject(target_proj)
1994
+
1995
+ # Map the composite function over the list of month start dates.
1996
+ monthly_composites_list = month_starts.map(create_monthly_composite)
1997
+
1998
+ # Convert the list of images into an ee.ImageCollection.
1999
+ monthly_collection = ee.ImageCollection.fromImages(monthly_composites_list)
2000
+
2001
+ # Filter out any composites that were created from zero images.
2002
+ # This prevents empty/masked images from being in the final collection.
2003
+ final_collection = LandsatCollection(collection=monthly_collection.filter(ee.Filter.gt('image_count', 0)))
2004
+ self._monthly_max = final_collection
2005
+ else:
2006
+ pass
2007
+
2008
+ return self._monthly_max
2009
+
2010
+ @property
2011
+ def monthly_min_collection(self):
2012
+ """Creates a monthly min composite from a LandsatCollection image collection.
2013
+
2014
+ This function computes the min for each
2015
+ month within the collection's date range, for each band in the collection. It automatically handles the full
2016
+ temporal extent of the input collection.
2017
+
2018
+ The resulting images have a 'system:time_start' property set to the
2019
+ first day of each month and an 'image_count' property indicating how
2020
+ many images were used in the composite. Months with no images are
2021
+ automatically excluded from the final collection.
2022
+
2023
+ NOTE: the day of month for the 'system:time_start' property is set to the earliest date of the first month observed and may not be the first day of the month.
2024
+
2025
+ Returns:
2026
+ LandsatCollection: A new LandsatCollection object with monthly min composites.
2027
+ """
2028
+ if self._monthly_min is None:
2029
+ collection = self.collection
2030
+ target_proj = collection.first().projection()
2031
+ # Get the start and end dates of the entire collection.
2032
+ date_range = collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
2033
+ original_start_date = ee.Date(date_range.get('min'))
2034
+ end_date = ee.Date(date_range.get('max'))
2035
+
2036
+ start_year = original_start_date.get('year')
2037
+ start_month = original_start_date.get('month')
2038
+ start_date = ee.Date.fromYMD(start_year, start_month, 1)
2039
+
2040
+ # Calculate the total number of months in the date range.
2041
+ # The .round() is important for ensuring we get an integer.
2042
+ num_months = end_date.difference(start_date, 'month').round()
2043
+
2044
+ # Generate a list of starting dates for each month.
2045
+ # This uses a sequence and advances the start date by 'i' months.
2046
+ def get_month_start(i):
2047
+ return start_date.advance(i, 'month')
2048
+
2049
+ month_starts = ee.List.sequence(0, num_months).map(get_month_start)
2050
+
2051
+ # Define a function to map over the list of month start dates.
2052
+ def create_monthly_composite(date):
2053
+ # Cast the input to an ee.Date object.
2054
+ start_of_month = ee.Date(date)
2055
+ # The end date is exclusive, so we advance by 1 month.
2056
+ end_of_month = start_of_month.advance(1, 'month')
2057
+
2058
+ # Filter the original collection to get images for the current month.
2059
+ monthly_subset = collection.filterDate(start_of_month, end_of_month)
2060
+
2061
+ # Count the number of images in the monthly subset.
2062
+ image_count = monthly_subset.size()
2063
+
2064
+ # Compute the min. This is robust to outliers like clouds.
2065
+ monthly_min = monthly_subset.min()
2066
+
2067
+ # Set essential properties on the resulting composite image.
2068
+ # The timestamp is crucial for time-series analysis and charting.
2069
+ # The image_count is useful metadata for quality assessment.
2070
+ return monthly_min.set({
2071
+ 'system:time_start': start_of_month.millis(),
2072
+ 'month': start_of_month.get('month'),
2073
+ 'year': start_of_month.get('year'),
2074
+ 'Date_Filter': start_of_month.format('YYYY-MM-dd'),
2075
+ 'image_count': image_count
2076
+ }).reproject(target_proj)
2077
+
2078
+ # Map the composite function over the list of month start dates.
2079
+ monthly_composites_list = month_starts.map(create_monthly_composite)
2080
+
2081
+ # Convert the list of images into an ee.ImageCollection.
2082
+ monthly_collection = ee.ImageCollection.fromImages(monthly_composites_list)
2083
+
2084
+ # Filter out any composites that were created from zero images.
2085
+ # This prevents empty/masked images from being in the final collection.
2086
+ final_collection = LandsatCollection(collection=monthly_collection.filter(ee.Filter.gt('image_count', 0)))
2087
+ self._monthly_min = final_collection
2088
+ else:
2089
+ pass
2090
+
2091
+ return self._monthly_min
1702
2092
 
1703
2093
  @property
1704
2094
  def ndwi(self):
@@ -2417,6 +2807,7 @@ class LandsatCollection:
2417
2807
  )
2418
2808
  return LandsatCollection(collection=col)
2419
2809
 
2810
+ @property
2420
2811
  def C_to_F(self):
2421
2812
  """
2422
2813
  Function to convert an LST collection from Celcius to Fahrenheit, adding a new band 'LST_F' to each image in the collection.
@@ -2424,9 +2815,10 @@ class LandsatCollection:
2424
2815
  Returns:
2425
2816
  LandsatCollection: A LandsatCollection image collection with LST in Fahrenheit as band titled 'LST_F'.
2426
2817
  """
2427
- if self._LST is None:
2428
- raise ValueError("LST has not been calculated yet. Access the LST property first.")
2429
- col = self._LST.collection.map(LandsatCollection.C_to_F_fn)
2818
+ # if self._LST is None:
2819
+ # raise ValueError("LST has not been calculated yet. Access the LST property first.")
2820
+ # col = self._LST.collection.map(LandsatCollection.C_to_F_fn)
2821
+ col = self.collection.map(LandsatCollection.C_to_F_fn)
2430
2822
  return LandsatCollection(collection=col)
2431
2823
 
2432
2824
  def mask_to_polygon(self, polygon):
@@ -2564,27 +2956,29 @@ class LandsatCollection:
2564
2956
  if threshold is None:
2565
2957
  raise ValueError("Threshold must be specified for binary masking.")
2566
2958
 
2959
+
2567
2960
  if classify_above_threshold:
2568
- if mask_zeros:
2961
+ if mask_zeros is True:
2569
2962
  col = self.collection.map(
2570
- lambda image: image.select(band_name).gte(threshold).rename(band_name).updateMask(image.select(band_name).gt(0)).copyProperties(image)
2963
+ lambda image: image.select(band_name).gte(threshold).rename(band_name).selfMask().copyProperties(image)
2571
2964
  )
2572
2965
  else:
2573
2966
  col = self.collection.map(
2574
2967
  lambda image: image.select(band_name).gte(threshold).rename(band_name).copyProperties(image)
2575
2968
  )
2576
2969
  else:
2577
- if mask_zeros:
2970
+ if mask_zeros is True:
2578
2971
  col = self.collection.map(
2579
- lambda image: image.select(band_name).lte(threshold).rename(band_name).updateMask(image.select(band_name).gt(0)).copyProperties(image)
2972
+ lambda image: image.select(band_name).lte(threshold).rename(band_name).selfMask().copyProperties(image)
2580
2973
  )
2581
2974
  else:
2582
2975
  col = self.collection.map(
2583
2976
  lambda image: image.select(band_name).lte(threshold).rename(band_name).copyProperties(image)
2584
2977
  )
2978
+
2585
2979
  return LandsatCollection(collection=col)
2586
2980
 
2587
- def anomaly(self, geometry, band_name=None, anomaly_band_name=None, replace=True):
2981
+ def anomaly(self, geometry, band_name=None, anomaly_band_name=None, replace=True, scale=30):
2588
2982
  """
2589
2983
  Calculates the anomaly of each image in a collection compared to the mean of each image.
2590
2984
 
@@ -2598,6 +2992,7 @@ class LandsatCollection:
2598
2992
  band_name (str, optional): A string representing the band name to be used for the output anomaly image. If not provided, the band name of the first band of the input image will be used.
2599
2993
  anomaly_band_name (str, optional): A string representing the band name to be used for the output anomaly image. If not provided, the band name of the first band of the input image will be used.
2600
2994
  replace (bool, optional): A boolean indicating whether to replace the original band with the anomaly band. If True, the output image will only contain the anomaly band. If False, the output image will retain all original bands and add the anomaly band. Default is True.
2995
+ scale (int, optional): The scale (in meters) to be used for image reduction. Default is 30 meters.
2601
2996
 
2602
2997
  Returns:
2603
2998
  LandsatCollection: A LandsatCollection where each image represents the anomaly (deviation from
@@ -2616,7 +3011,7 @@ class LandsatCollection:
2616
3011
  else:
2617
3012
  band_name = band_names.get(0).getInfo()
2618
3013
 
2619
- col = self.collection.map(lambda image: LandsatCollection.anomaly_fn(image, geometry=geometry, band_name=band_name, anomaly_band_name=anomaly_band_name, replace=replace))
3014
+ col = self.collection.map(lambda image: LandsatCollection.anomaly_fn(image, geometry=geometry, band_name=band_name, anomaly_band_name=anomaly_band_name, replace=replace, scale=scale))
2620
3015
  return LandsatCollection(collection=col)
2621
3016
 
2622
3017
  def mask_via_band(self, band_to_mask, band_for_mask, threshold=-1, mask_above=True, add_band_to_original_image=False):
@@ -3447,24 +3842,30 @@ class LandsatCollection:
3447
3842
  ValueError: If input parameters are invalid.
3448
3843
  TypeError: If geometries input type is unsupported.
3449
3844
  """
3845
+ # Create a local reference to the collection object to allow for modifications (like band selection) without altering the original instance
3450
3846
  img_collection_obj = self
3847
+
3848
+ # If a specific band is requested, select only that band
3451
3849
  if band:
3452
3850
  img_collection_obj = LandsatCollection(collection=img_collection_obj.collection.select(band))
3453
3851
  else:
3852
+ # If no band is specified, default to using the first band of the first image in the collection
3454
3853
  first_image = img_collection_obj.image_grab(0)
3455
3854
  first_band = first_image.bandNames().get(0)
3456
3855
  img_collection_obj = LandsatCollection(collection=img_collection_obj.collection.select([first_band]))
3457
- # Filter collection by dates if provided
3856
+
3857
+ # If a list of dates is provided, filter the collection to include only images matching those dates
3458
3858
  if dates:
3459
3859
  img_collection_obj = LandsatCollection(
3460
3860
  collection=self.collection.filter(ee.Filter.inList('Date_Filter', dates))
3461
3861
  )
3462
3862
 
3463
- # Initialize variables
3863
+ # Initialize variables to hold the standardized feature collection and coordinates
3464
3864
  features = None
3465
3865
  validated_coordinates = []
3466
3866
 
3467
- # Function to standardize feature names if no names are provided
3867
+ # Define a helper function to ensure every feature has a standardized 'geo_name' property
3868
+ # This handles features that might have different existing name properties or none at all
3468
3869
  def set_standard_name(feature):
3469
3870
  has_geo_name = feature.get('geo_name')
3470
3871
  has_name = feature.get('name')
@@ -3475,33 +3876,38 @@ class LandsatCollection:
3475
3876
  ee.Algorithms.If(has_index, has_index, 'unnamed_geometry')))
3476
3877
  return feature.set({'geo_name': new_name})
3477
3878
 
3879
+ # Handle input: FeatureCollection or single Feature
3478
3880
  if isinstance(geometries, (ee.FeatureCollection, ee.Feature)):
3479
3881
  features = ee.FeatureCollection(geometries)
3480
3882
  if geometry_names:
3481
3883
  print("Warning: 'geometry_names' are ignored when the input is an ee.Feature or ee.FeatureCollection.")
3482
3884
 
3885
+ # Handle input: Single ee.Geometry
3483
3886
  elif isinstance(geometries, ee.Geometry):
3484
3887
  name = geometry_names[0] if (geometry_names and geometry_names[0]) else 'unnamed_geometry'
3485
3888
  features = ee.FeatureCollection([ee.Feature(geometries).set('geo_name', name)])
3486
3889
 
3890
+ # Handle input: List (could be coordinates or ee.Geometry objects)
3487
3891
  elif isinstance(geometries, list):
3488
3892
  if not geometries: # Handle empty list case
3489
3893
  raise ValueError("'geometries' list cannot be empty.")
3490
3894
 
3491
- # Case: List of coordinates
3895
+ # Case: List of tuples (coordinates)
3492
3896
  if all(isinstance(i, tuple) for i in geometries):
3493
3897
  validated_coordinates = geometries
3898
+ # Generate default names if none provided
3494
3899
  if geometry_names is None:
3495
3900
  geometry_names = [f"Location_{i+1}" for i in range(len(validated_coordinates))]
3496
3901
  elif len(geometry_names) != len(validated_coordinates):
3497
3902
  raise ValueError("geometry_names must have the same length as the coordinates list.")
3903
+ # Create features with buffers around the coordinates
3498
3904
  points = [
3499
3905
  ee.Feature(ee.Geometry.Point(coord).buffer(buffer_size), {'geo_name': str(name)})
3500
3906
  for coord, name in zip(validated_coordinates, geometry_names)
3501
3907
  ]
3502
3908
  features = ee.FeatureCollection(points)
3503
3909
 
3504
- # Case: List of Geometries
3910
+ # Case: List of ee.Geometry objects
3505
3911
  elif all(isinstance(i, ee.Geometry) for i in geometries):
3506
3912
  if geometry_names is None:
3507
3913
  geometry_names = [f"Geometry_{i+1}" for i in range(len(geometries))]
@@ -3516,6 +3922,7 @@ class LandsatCollection:
3516
3922
  else:
3517
3923
  raise TypeError("Input list must be a list of (lon, lat) tuples OR a list of ee.Geometry objects.")
3518
3924
 
3925
+ # Handle input: Single tuple (coordinate)
3519
3926
  elif isinstance(geometries, tuple) and len(geometries) == 2:
3520
3927
  name = geometry_names[0] if geometry_names else 'Location_1'
3521
3928
  features = ee.FeatureCollection([
@@ -3524,39 +3931,48 @@ class LandsatCollection:
3524
3931
  else:
3525
3932
  raise TypeError("Unsupported type for 'geometries'.")
3526
3933
 
3934
+ # Apply the naming standardization to the created FeatureCollection
3527
3935
  features = features.map(set_standard_name)
3528
3936
 
3937
+ # Dynamically retrieve the Earth Engine reducer based on the string name provided
3529
3938
  try:
3530
3939
  reducer = getattr(ee.Reducer, reducer_type)()
3531
3940
  except AttributeError:
3532
3941
  raise ValueError(f"Unknown reducer_type: '{reducer_type}'.")
3533
3942
 
3943
+ # Define the function to map over the image collection
3534
3944
  def calculate_stats_for_image(image):
3535
3945
  image_date = image.get('Date_Filter')
3946
+ # Calculate statistics for all geometries in 'features' for this specific image
3536
3947
  stats_fc = image.reduceRegions(
3537
3948
  collection=features, reducer=reducer, scale=scale, tileScale=tileScale
3538
3949
  )
3539
3950
 
3951
+ # Helper to ensure the result has the reducer property, even if masked
3952
+ # If the property is missing (e.g., all pixels masked), set it to a sentinel value (-9999)
3540
3953
  def guarantee_reducer_property(f):
3541
3954
  has_property = f.propertyNames().contains(reducer_type)
3542
3955
  return ee.Algorithms.If(has_property, f, f.set(reducer_type, -9999))
3956
+
3957
+ # Apply the guarantee check
3543
3958
  fixed_stats_fc = stats_fc.map(guarantee_reducer_property)
3544
3959
 
3960
+ # Attach the image date to every feature in the result so we know which image it came from
3545
3961
  return fixed_stats_fc.map(lambda f: f.set('image_date', image_date))
3546
3962
 
3963
+ # Map the calculation over the image collection and flatten the resulting FeatureCollections into one
3547
3964
  results_fc = ee.FeatureCollection(img_collection_obj.collection.map(calculate_stats_for_image)).flatten()
3965
+
3966
+ # Convert the Earth Engine FeatureCollection to a pandas DataFrame (client-side operation)
3548
3967
  df = LandsatCollection.ee_to_df(results_fc, remove_geom=True)
3549
3968
 
3550
- # Checking for issues
3969
+ # Check for empty results or missing columns
3551
3970
  if df.empty:
3552
- # 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.")
3553
- # return df
3554
3971
  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.")
3555
3972
  if reducer_type not in df.columns:
3556
3973
  print(f"Warning: Reducer '{reducer_type}' not found in results.")
3557
- # return df
3558
3974
 
3559
- # Get the number of rows before dropping nulls for a helpful message
3975
+ # Filter out the sentinel values (-9999) which indicate failed reductions/masked pixels
3560
3976
  initial_rows = len(df)
3561
3977
  df.dropna(subset=[reducer_type], inplace=True)
3562
3978
  df = df[df[reducer_type] != -9999]
@@ -3564,9 +3980,18 @@ class LandsatCollection:
3564
3980
  if dropped_rows > 0:
3565
3981
  print(f"Warning: Discarded {dropped_rows} results due to failed reductions (e.g., no valid pixels in geometry).")
3566
3982
 
3567
- # Reshape DataFrame to have dates as index and geometry names as columns
3983
+ # Pivot the DataFrame so that each row represents a date and each column represents a geometry location
3568
3984
  pivot_df = df.pivot(index='image_date', columns='geo_name', values=reducer_type)
3985
+ # Rename the column headers (geometry names) to include the reducer type
3986
+ pivot_df.columns = [f"{col}_{reducer_type}" for col in pivot_df.columns]
3987
+ # Rename the index axis to 'Date' so it is correctly labeled when moved to a column later
3569
3988
  pivot_df.index.name = 'Date'
3989
+ # Remove the name of the columns axis (which defaults to 'geo_name') so it doesn't appear as a confusing label in the final output
3990
+ pivot_df.columns.name = None
3991
+ # Reset the index to move the 'Date' index into a regular column and create a standard numerical index (0, 1, 2...)
3992
+ pivot_df = pivot_df.reset_index(drop=False)
3993
+
3994
+ # If a file path is provided, save the resulting DataFrame to CSV
3570
3995
  if file_path:
3571
3996
  # Check if file_path ends with .csv and remove it if so for consistency
3572
3997
  if file_path.endswith('.csv'):