geoextract-gee 0.2.0__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.
File without changes
@@ -0,0 +1,128 @@
1
+ import hashlib
2
+ import time
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ import ee
7
+ from google.cloud import storage
8
+
9
+ from .datasets.common import gee_setting
10
+
11
+ def get_file_md5(file_path: Path) -> str:
12
+ """Calculates the MD5 hash of a local file."""
13
+ hash_md5 = hashlib.md5()
14
+ with open(file_path, "rb") as f:
15
+ for chunk in iter(lambda: f.read(4096), b""):
16
+ hash_md5.update(chunk)
17
+ return hash_md5.hexdigest()
18
+
19
+ def check_asset_exists_and_matches(asset_id: str, local_md5: str) -> bool:
20
+ """Checks if an EE asset exists and has a matching local_md5 property."""
21
+ try:
22
+ asset = ee.data.getAsset(asset_id)
23
+ asset_md5 = asset.get('properties', {}).get('local_md5')
24
+ return asset_md5 == local_md5
25
+ except ee.ee_exception.EEException:
26
+ # Asset doesn't exist or we don't have access
27
+ return False
28
+
29
+ def upload_to_gcs(local_path: Path, bucket_name: str, logger) -> str:
30
+ """Uploads a local file to GCS and returns the gs:// URI."""
31
+ client = storage.Client()
32
+ bucket = client.bucket(bucket_name)
33
+ # Put them in a staging folder so they don't clutter the export directory
34
+ dest_blob_name = f"gee_staging/{local_path.name}"
35
+ blob = bucket.blob(dest_blob_name)
36
+
37
+ logger.info(f"Uploading {local_path.name} to gs://{bucket_name}/{dest_blob_name}...")
38
+ blob.upload_from_filename(str(local_path))
39
+ return f"gs://{bucket_name}/{dest_blob_name}"
40
+
41
+ def start_ingestion(gcs_uri: str, asset_id: str, local_md5: str):
42
+ """Triggers an Earth Engine ingestion task from a GCS URI."""
43
+ manifest = {
44
+ "name": asset_id,
45
+ "tilesets": [
46
+ {"id": "tileset_1", "sources": [{"uris": [gcs_uri]}]}
47
+ ],
48
+ "properties": {
49
+ "local_md5": local_md5
50
+ }
51
+ }
52
+ request_id = str(uuid.uuid4())
53
+ ee.data.startIngestion(request_id, manifest)
54
+
55
+ def sync_cropmasks(params, unique_paths: set) -> dict:
56
+ """
57
+ Synchronizes local TIFF cropmasks with Earth Engine.
58
+ Returns a dict mapping the local Path string to the final GEE Asset ID.
59
+ """
60
+ params.logger.info(f"Synchronizing {len(unique_paths)} unique cropmasks with Earth Engine...")
61
+
62
+ project_name = gee_setting(params, "project")
63
+ bucket_name = gee_setting(params, "bucket")
64
+
65
+ # 1. Ensure the parent folder exists in GEE
66
+ folder_id = f"projects/{project_name}/assets/geoextract"
67
+ try:
68
+ ee.data.createAsset({'type': 'FOLDER'}, folder_id)
69
+ params.logger.info(f"Created new GEE folder: {folder_id}")
70
+ except ee.ee_exception.EEException:
71
+ pass # Already exists
72
+
73
+ cropmask_map = {}
74
+ pending_ingestions = []
75
+
76
+ # 2. Check and stage files
77
+ for path in unique_paths:
78
+ path_obj = Path(path)
79
+ asset_name = path_obj.stem # Removes the path and .tif extension
80
+ asset_id = f"{folder_id}/{asset_name}"
81
+ cropmask_map[str(path)] = asset_id
82
+
83
+ if not path_obj.exists():
84
+ params.logger.warning(f"Cropmask file not found locally: {path_obj}")
85
+ continue
86
+
87
+ md5 = get_file_md5(path_obj)
88
+
89
+ # Check if it already exists in GEE and matches our hash
90
+ if check_asset_exists_and_matches(asset_id, md5):
91
+ params.logger.debug(f"Asset {asset_name} already up to date in GEE.")
92
+ continue
93
+
94
+ # It needs to be uploaded
95
+ gcs_uri = upload_to_gcs(path_obj, bucket_name, params.logger)
96
+
97
+ params.logger.info(f"Triggering ingestion for {asset_name}...")
98
+ start_ingestion(gcs_uri, asset_id, md5)
99
+ pending_ingestions.append(asset_id)
100
+
101
+ # 3. Block and wait for ingestions to finish
102
+ if pending_ingestions:
103
+ params.logger.info(f"Waiting for {len(pending_ingestions)} cropmasks to finish ingesting...")
104
+
105
+ while True:
106
+ ops = ee.data.listOperations()
107
+ active_ingestions = 0
108
+
109
+ for op in ops:
110
+ metadata = op.get('metadata', {})
111
+ state = metadata.get('state')
112
+
113
+ # Check if it's an image ingestion task
114
+ if metadata.get('type') == 'INGEST_IMAGE':
115
+ if state in ['PENDING', 'RUNNING']:
116
+ active_ingestions += 1
117
+ elif state == 'FAILED':
118
+ # Optionally check if it's one of ours, but usually safe to log
119
+ params.logger.error(f"An ingestion failed: {op.get('name')}")
120
+
121
+ if active_ingestions == 0:
122
+ params.logger.info("All cropmask ingestions have completed.")
123
+ break
124
+
125
+ params.logger.info(f"{active_ingestions} assets still ingesting. Waiting 30s...")
126
+ time.sleep(30)
127
+
128
+ return cropmask_map
File without changes
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import ee
4
+ from .common import TaskConfig, transform_geometry
5
+
6
+ # --- Dataset Constants ---
7
+ AEF_COLLECTION_ID = "GOOGLE/SATELLITE_EMBEDDING/V1/ANNUAL"
8
+
9
+ def _aef_stats_feature_batch(
10
+ avg_img: ee.Image,
11
+ config: TaskConfig,
12
+ mask_threshold_percent: float,
13
+ ) -> ee.FeatureCollection:
14
+ """
15
+ One row per region representing the multi-year average of
16
+ the 64 AEF embedding bands over the crop-masked area.
17
+ """
18
+ avg_img = ee.Image(avg_img)
19
+
20
+ afi_thresh = ee.Number(float(mask_threshold_percent * 100))
21
+ fc_bounds = config.geometry_r.geometry().bounds(1)
22
+ w_raw = ee.Image(config.cropmask_asset).float().clip(fc_bounds)
23
+ w = w_raw.updateMask(w_raw.gt(afi_thresh))
24
+
25
+ # Mask the AEF image to the crop area
26
+ new_names = [f"aef_{i+1}" for i in range(64)]
27
+
28
+ p = avg_img.rename(new_names).float().clip(fc_bounds)
29
+ pf = p.updateMask(w.mask())
30
+
31
+ reducer_common_parms = {
32
+ "crs": config.reduce_crs,
33
+ "scale": config.reduce_scale,
34
+ "maxPixels": 1e9,
35
+ "tileScale": 4,
36
+ "bestEffort": True,
37
+ }
38
+
39
+ def _process_region(feat):
40
+ geom = feat.geometry()
41
+
42
+ # Returns a dict like {"A00": 0.123, "A01": 0.456, ...}
43
+ d_mean = pf.reduceRegion(
44
+ reducer=ee.Reducer.mean().unweighted(),
45
+ geometry=geom,
46
+ **reducer_common_parms
47
+ )
48
+
49
+ props = ee.Dictionary({
50
+ "region_label": feat.get("region_label"),
51
+ "region_id": feat.get("region_id")
52
+ }).combine(d_mean)
53
+
54
+ return ee.Feature(None, props)
55
+
56
+ return ee.FeatureCollection(config.geometry_r).map(_process_region)
57
+
58
+
59
+ def create_task(config: TaskConfig, mask_threshold_percent: float) -> ee.batch.Task:
60
+ """Creates a GEE Export task for AEF (AlphaEarth Foundations) 64-band data."""
61
+
62
+ # AEF has data from 2018 onwards
63
+ start_year = int(config.date_from.split('-')[0])
64
+ end_year = int(config.date_to.split('-')[0])
65
+
66
+ start_year = max(2018, start_year)
67
+ end_year = min(2025, end_year - 1)
68
+
69
+ # If the user requested a year completely outside AEF bounds (e.g., 2026-2027),
70
+ # we fall back to the global 2018-2025 average so geomerge still gets the columns.
71
+ # This mimics extract_EO using the cached aef_avg.tif for future years
72
+ if start_year > end_year:
73
+ start_year = 2018
74
+ end_year = 2025
75
+
76
+ # Annual images are at 01-01T00:00:00Z,
77
+ ic = ee.ImageCollection(AEF_COLLECTION_ID).filterDate(f"{start_year}-01-01", f"{end_year}-01-02")
78
+
79
+ avg_image = ic.mean()
80
+
81
+ # ensure projection equals dataset projection to decrease reducers cost
82
+ aef_proj = ee.Projection(
83
+ ee.Algorithms.If(
84
+ ic.size().gt(0),
85
+ ic.first().select("A00").projection(),
86
+ ee.Projection('EPSG:4326')
87
+ )
88
+ )
89
+
90
+ config.geometry_r = transform_geometry(config.geometry_ee, aef_proj)
91
+ config.reduce_crs = aef_proj.crs()
92
+
93
+ # Force scale to 5600m (0.05 degrees) to match local equivalents
94
+ # and leverage GEE Image Pyramids for the 64-band reduction
95
+ config.reduce_scale = 5600
96
+
97
+ # We only want one row per region
98
+ # So wrap the single average image in a single-feature FeatureCollection.
99
+ col = _aef_stats_feature_batch(avg_image, config, mask_threshold_percent)
100
+
101
+ # just region_label and 64 AEF bands
102
+ selectors = ["region_label"] + [f"aef_{i+1}" for i in range(64)]
103
+
104
+ task_desc = config.task_desc
105
+
106
+ return ee.batch.Export.table.toCloudStorage(
107
+ collection=col,
108
+ description=task_desc[:100],
109
+ bucket=config.export_bucket,
110
+ fileNamePrefix=config.export_prefix,
111
+ fileFormat="CSV",
112
+ selectors=selectors,
113
+ )
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import ee
4
+ from .common import TaskConfig, transform_geometry
5
+
6
+ # --- Dataset Constants ---
7
+ AGERA5_COLLECTION_ID = "projects/climate-engine-pro/assets/ce-ag-era5-v2/daily"
8
+
9
+ # var -> band. Only variables the tiles backend also extracts, so both backends
10
+ # give the same columns (docs/decisions/001).
11
+ AGERA5_BANDS = {
12
+ "agera5_snow_thickness_lwe": "Snow_Thickness_LWE_Mean_24h",
13
+ "agera5_dewpoint": "Dew_Point_Temperature_2m_Mean_24h",
14
+ }
15
+
16
+ def _agera5_daily_stats_feature_batch(
17
+ img: ee.Image,
18
+ config: TaskConfig,
19
+ band_name: str,
20
+ mask_threshold_percent: float,
21
+ ) -> ee.FeatureCollection:
22
+ """
23
+ One row per day per region, aligned with geoextract geom_extract + arr_stats.
24
+ """
25
+ img = ee.Image(img)
26
+
27
+ afi_thresh = ee.Number(float(mask_threshold_percent * 100))
28
+ fc_bounds = config.geometry_r.geometry().bounds(1)
29
+ w_raw = ee.Image(config.cropmask_asset).float().clip(fc_bounds)
30
+ w = w_raw.updateMask(w_raw.gt(afi_thresh))
31
+
32
+
33
+ p = (
34
+ img.select(band_name)
35
+ .float()
36
+ .clip(fc_bounds)
37
+ )
38
+
39
+ # Same masks and units as the tiles backend (geoprepare extract/stats.py)
40
+ if config.var == 'agera5_snow_thickness_lwe':
41
+ p = p.updateMask(p.gte(0))
42
+ elif config.var == 'agera5_dewpoint':
43
+ # Kelvin -> deg C; below 150 K is fill, not weather
44
+ p = p.updateMask(p.gte(150)).subtract(273.15)
45
+
46
+ pf = p.updateMask(w.mask())
47
+ pf_and_w = pf.addBands(w.rename("weight"))
48
+
49
+ if config.include_audit:
50
+ stack_audit = ee.Image.cat([
51
+ ee.Image.constant(1).clip(fc_bounds).rename("total_w"),
52
+ ee.Image.constant(1).updateMask(p.mask()).rename("valid_p_w"),
53
+ w.rename("w_all"),
54
+ w.updateMask(pf.mask()).rename("w_used"),
55
+ pf.pow(2).multiply(w).rename("p2w"),
56
+ ]).float()
57
+ else:
58
+ stack_audit = None
59
+
60
+ reducer_common_parms = {
61
+ "crs": config.reduce_crs,
62
+ "scale": config.reduce_scale,
63
+ "maxPixels": 1e9,
64
+ "tileScale": 4,
65
+ "bestEffort": True,
66
+ }
67
+
68
+ def _process_region(feat):
69
+ geom = feat.geometry()
70
+
71
+ # Common stats: mean, min, max, median, count (valid after mask)
72
+ d_mean = pf_and_w.reduceRegion(
73
+ reducer=ee.Reducer.mean().splitWeights(),
74
+ geometry=geom,
75
+ **reducer_common_parms
76
+ )
77
+
78
+ # Combined min, max, median, and unweighted count
79
+ d_stats = pf.reduceRegion(
80
+ reducer=ee.Reducer.minMax()
81
+ .combine(ee.Reducer.median().unweighted(), sharedInputs=True)
82
+ .combine(ee.Reducer.count().unweighted(), sharedInputs=True),
83
+ geometry=geom,
84
+ **reducer_common_parms
85
+ )
86
+
87
+ valid_after = d_stats.getNumber(f"{band_name}_count")
88
+ mean_raw = d_mean.getNumber("mean")
89
+
90
+ props = ee.Dictionary({
91
+ "date": img.date().format("YYYY-MM-dd"),
92
+ "region_label": feat.get("region_label"),
93
+ "region_id": feat.get("region_id"),
94
+ "stats_mean": mean_raw,
95
+ "stats_min": d_stats.get(f"{band_name}_min"),
96
+ "stats_max": d_stats.get(f"{band_name}_max"),
97
+ "stats_median": d_stats.get(f"{band_name}_median"),
98
+ "stats_count": valid_after,
99
+ })
100
+
101
+ if config.include_audit:
102
+ d_audit = stack_audit.reduceRegion(
103
+ reducer=ee.Reducer.sum().unweighted(),
104
+ geometry=geom,
105
+ **reducer_common_parms
106
+ )
107
+
108
+ w_used_sum = d_audit.getNumber("w_used")
109
+ sum_p2w = d_audit.getNumber("p2w")
110
+
111
+ # Weighted Std Dev logic
112
+ var_raw = ee.Number(
113
+ ee.Algorithms.If(
114
+ w_used_sum.gt(0),
115
+ sum_p2w.divide(w_used_sum).subtract(mean_raw.pow(2)),
116
+ 0,
117
+ )
118
+ )
119
+ std_mm = ee.Number(
120
+ ee.Algorithms.If(
121
+ w_used_sum.lte(0),
122
+ None,
123
+ ee.Algorithms.If(valid_after.gt(1), var_raw.max(0).sqrt(), 0),
124
+ )
125
+ )
126
+
127
+ props = props.combine(ee.Dictionary({
128
+ "counts_total": d_audit.getNumber("total_w"),
129
+ "counts_valid_data": d_audit.getNumber("valid_p_w"),
130
+ "counts_valid_data_after_masking": valid_after,
131
+ "counts_weight_sum": d_audit.getNumber("w_all"),
132
+ "counts_weight_sum_used": w_used_sum,
133
+ "stats_std": std_mm,
134
+ }))
135
+
136
+ return ee.Feature(None, props)
137
+
138
+ return ee.FeatureCollection(config.geometry_r).map(_process_region)
139
+
140
+
141
+ def create_task(config: TaskConfig, mask_threshold_percent: float) -> ee.batch.Task:
142
+ """Creates a GEE Export task for AgERA5 data."""
143
+ ic = (
144
+ ee.ImageCollection(AGERA5_COLLECTION_ID)
145
+ .filterDate(config.date_from, config.date_to)
146
+ .filterBounds(config.geometry_ee)
147
+ )
148
+
149
+ band_name = AGERA5_BANDS[config.var]
150
+
151
+ # ensure projection equals dataset projection to decrease reducers cost
152
+ agera5_proj = ee.Projection(
153
+ ee.Algorithms.If(
154
+ ic.size().gt(0),
155
+ ic.first().select(band_name).projection(),
156
+ ee.Projection('EPSG:4326')
157
+ )
158
+ )
159
+
160
+ config.geometry_r = transform_geometry(config.geometry_ee, agera5_proj)
161
+ config.reduce_crs = agera5_proj.crs()
162
+ config.reduce_scale = agera5_proj.nominalScale()
163
+
164
+ col = ic.map(lambda im: _agera5_daily_stats_feature_batch(im, config, band_name, mask_threshold_percent)).flatten()
165
+
166
+ selectors = ["date", "region_label", "stats_mean", "stats_min", "stats_max", "stats_median", "stats_count"]
167
+ if config.include_audit:
168
+ selectors += [
169
+ "counts_total", "counts_valid_data", "counts_valid_data_after_masking",
170
+ "counts_weight_sum", "counts_weight_sum_used", "stats_std"
171
+ ]
172
+
173
+ task_desc = config.task_desc
174
+
175
+ return ee.batch.Export.table.toCloudStorage(
176
+ collection=col,
177
+ description=task_desc[:100],
178
+ bucket=config.export_bucket,
179
+ fileNamePrefix=config.export_prefix,
180
+ fileFormat="CSV",
181
+ selectors=selectors,
182
+ )
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import ee
4
+ from .common import TaskConfig, transform_geometry
5
+
6
+ # --- Dataset Constants ---
7
+ CHIRPS_COLLECTION_ID = "UCSB-CHC/CHIRPS/V3/DAILY_SAT"
8
+
9
+ def _chirps_daily_stats_feature_batch(
10
+ img: ee.Image,
11
+ config: TaskConfig,
12
+ mask_threshold_percent: float,
13
+ ) -> ee.FeatureCollection:
14
+ """
15
+ One row per day per region, aligned with geoextract geom_extract + arr_stats (chirps + AFI).
16
+ """
17
+ img = ee.Image(img)
18
+
19
+ afi_thresh = ee.Number(float(mask_threshold_percent * 100))
20
+ fc_bounds = config.geometry_r.geometry().bounds(1)
21
+ w_raw = ee.Image(config.cropmask_asset).float().clip(fc_bounds)
22
+ w = w_raw.updateMask(w_raw.gt(afi_thresh))
23
+
24
+ p = (
25
+ img.select("precipitation")
26
+ .float()
27
+ .multiply(100)
28
+ .int16()
29
+ .divide(100)
30
+ .clip(fc_bounds)
31
+ )
32
+ pf = p.updateMask(p.gte(0)).updateMask(w.mask())
33
+ pf_and_w = pf.addBands(w.rename("weight"))
34
+
35
+ if config.include_audit:
36
+ stack_audit = ee.Image.cat([
37
+ ee.Image.constant(1).clip(fc_bounds).rename("total_w"),
38
+ ee.Image.constant(1).updateMask(p.mask()).rename("valid_p_w"),
39
+ w.rename("w_all"),
40
+ w.updateMask(pf.mask()).rename("w_used"),
41
+ pf.pow(2).multiply(w).rename("p2w"),
42
+ ]).float()
43
+ else:
44
+ stack_audit = None
45
+
46
+ reducer_common_parms = {
47
+ "crs": config.reduce_crs,
48
+ "scale": config.reduce_scale,
49
+ "maxPixels": 1e9,
50
+ "tileScale": 4,
51
+ "bestEffort": True,
52
+ }
53
+
54
+ def _process_region(feat):
55
+ geom = feat.geometry()
56
+
57
+ # Common stats: mean, min, max, median, count (valid after mask)
58
+ d_mean = pf_and_w.reduceRegion(
59
+ reducer=ee.Reducer.mean().splitWeights(),
60
+ geometry=geom,
61
+ **reducer_common_parms
62
+ )
63
+
64
+ # Combined min, max, median, and unweighted count
65
+ d_stats = pf.reduceRegion(
66
+ reducer=ee.Reducer.minMax()
67
+ .combine(ee.Reducer.median().unweighted(), sharedInputs=True)
68
+ .combine(ee.Reducer.count().unweighted(), sharedInputs=True),
69
+ geometry=geom,
70
+ **reducer_common_parms
71
+ )
72
+
73
+ valid_after = d_stats.getNumber("precipitation_count")
74
+ mean_raw = d_mean.getNumber("mean")
75
+
76
+ props = ee.Dictionary({
77
+ "date": img.date().format("YYYY-MM-dd"),
78
+ "region_label": feat.get("region_label"),
79
+ "region_id": feat.get("region_id"),
80
+ "stats_mean": mean_raw,
81
+ "stats_min": d_stats.get("precipitation_min"),
82
+ "stats_max": d_stats.get("precipitation_max"),
83
+ "stats_median": d_stats.get("precipitation_median"),
84
+ "stats_count": valid_after,
85
+ })
86
+
87
+ if config.include_audit:
88
+ d_audit = stack_audit.reduceRegion(
89
+ reducer=ee.Reducer.sum().unweighted(),
90
+ geometry=geom,
91
+ **reducer_common_parms
92
+ )
93
+
94
+ w_used_sum = d_audit.getNumber("w_used")
95
+ sum_p2w = d_audit.getNumber("p2w")
96
+
97
+ # Weighted Std Dev logic
98
+ var_raw = ee.Number(
99
+ ee.Algorithms.If(
100
+ w_used_sum.gt(0),
101
+ sum_p2w.divide(w_used_sum).subtract(mean_raw.pow(2)),
102
+ 0,
103
+ )
104
+ )
105
+ std_mm = ee.Number(
106
+ ee.Algorithms.If(
107
+ w_used_sum.lte(0),
108
+ None,
109
+ ee.Algorithms.If(valid_after.gt(1), var_raw.max(0).sqrt(), 0),
110
+ )
111
+ )
112
+
113
+ props = props.combine(ee.Dictionary({
114
+ "counts_total": d_audit.getNumber("total_w"),
115
+ "counts_valid_data": d_audit.getNumber("valid_p_w"),
116
+ "counts_valid_data_after_masking": valid_after,
117
+ "counts_weight_sum": d_audit.getNumber("w_all"),
118
+ "counts_weight_sum_used": w_used_sum,
119
+ "stats_std": std_mm,
120
+ }))
121
+
122
+ return ee.Feature(None, props)
123
+
124
+ return ee.FeatureCollection(config.geometry_r).map(_process_region)
125
+
126
+
127
+ def create_task(config: TaskConfig, mask_threshold_percent: float) -> ee.batch.Task:
128
+ """Creates a GEE Export task for CHIRPS data."""
129
+ ic = (
130
+ ee.ImageCollection(CHIRPS_COLLECTION_ID)
131
+ .filterDate(config.date_from, config.date_to)
132
+ .filterBounds(config.geometry_ee)
133
+ )
134
+
135
+ # ensure projection equals dataset projection to decrease reducers cost
136
+ chirps_proj = ee.Projection(
137
+ ee.Algorithms.If(
138
+ ic.size().gt(0),
139
+ ic.first().select("precipitation").projection(),
140
+ ee.Projection('EPSG:4326')
141
+ )
142
+ )
143
+
144
+ config.geometry_r = transform_geometry(config.geometry_ee, chirps_proj)
145
+ config.reduce_crs = chirps_proj.crs()
146
+ config.reduce_scale = chirps_proj.nominalScale()
147
+
148
+ col = ic.map(lambda im: _chirps_daily_stats_feature_batch(im, config, mask_threshold_percent)).flatten()
149
+
150
+ selectors = ["date", "region_label", "stats_mean", "stats_min", "stats_max", "stats_median", "stats_count"]
151
+ if config.include_audit:
152
+ selectors += [
153
+ "counts_total", "counts_valid_data", "counts_valid_data_after_masking",
154
+ "counts_weight_sum", "counts_weight_sum_used", "stats_std"
155
+ ]
156
+
157
+ # GEE task description only allows letters, numbers, -, and _
158
+ # task_desc is now centralized in TaskConfig
159
+ task_desc = config.task_desc
160
+
161
+ return ee.batch.Export.table.toCloudStorage(
162
+ collection=col,
163
+ description=task_desc[:100],
164
+ bucket=config.export_bucket,
165
+ fileNamePrefix=config.export_prefix,
166
+ fileFormat="CSV",
167
+ selectors=selectors,
168
+ )