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.
- geoextract_gee/__init__.py +0 -0
- geoextract_gee/asset_manager.py +128 -0
- geoextract_gee/datasets/__init__.py +0 -0
- geoextract_gee/datasets/aef.py +113 -0
- geoextract_gee/datasets/agera5.py +182 -0
- geoextract_gee/datasets/chirps.py +168 -0
- geoextract_gee/datasets/common.py +136 -0
- geoextract_gee/datasets/cpc.py +181 -0
- geoextract_gee/datasets/esi.py +169 -0
- geoextract_gee/datasets/ndvi.py +201 -0
- geoextract_gee/datasets/nsidc.py +195 -0
- geoextract_gee/datasets/soilgrids.py +98 -0
- geoextract_gee/datasets/viirs.py +185 -0
- geoextract_gee/extract_EO_gee.py +932 -0
- geoextract_gee-0.2.0.dist-info/METADATA +123 -0
- geoextract_gee-0.2.0.dist-info/RECORD +19 -0
- geoextract_gee-0.2.0.dist-info/WHEEL +5 -0
- geoextract_gee-0.2.0.dist-info/licenses/LICENSE +21 -0
- geoextract_gee-0.2.0.dist-info/top_level.txt +1 -0
|
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
|
+
)
|