geoextract-gee 0.2.0__tar.gz

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.
Files changed (26) hide show
  1. geoextract_gee-0.2.0/LICENSE +21 -0
  2. geoextract_gee-0.2.0/PKG-INFO +123 -0
  3. geoextract_gee-0.2.0/README.md +95 -0
  4. geoextract_gee-0.2.0/geoextract_gee/__init__.py +0 -0
  5. geoextract_gee-0.2.0/geoextract_gee/asset_manager.py +128 -0
  6. geoextract_gee-0.2.0/geoextract_gee/datasets/__init__.py +0 -0
  7. geoextract_gee-0.2.0/geoextract_gee/datasets/aef.py +113 -0
  8. geoextract_gee-0.2.0/geoextract_gee/datasets/agera5.py +182 -0
  9. geoextract_gee-0.2.0/geoextract_gee/datasets/chirps.py +168 -0
  10. geoextract_gee-0.2.0/geoextract_gee/datasets/common.py +136 -0
  11. geoextract_gee-0.2.0/geoextract_gee/datasets/cpc.py +181 -0
  12. geoextract_gee-0.2.0/geoextract_gee/datasets/esi.py +169 -0
  13. geoextract_gee-0.2.0/geoextract_gee/datasets/ndvi.py +201 -0
  14. geoextract_gee-0.2.0/geoextract_gee/datasets/nsidc.py +195 -0
  15. geoextract_gee-0.2.0/geoextract_gee/datasets/soilgrids.py +98 -0
  16. geoextract_gee-0.2.0/geoextract_gee/datasets/viirs.py +185 -0
  17. geoextract_gee-0.2.0/geoextract_gee/extract_EO_gee.py +932 -0
  18. geoextract_gee-0.2.0/geoextract_gee.egg-info/PKG-INFO +123 -0
  19. geoextract_gee-0.2.0/geoextract_gee.egg-info/SOURCES.txt +24 -0
  20. geoextract_gee-0.2.0/geoextract_gee.egg-info/dependency_links.txt +1 -0
  21. geoextract_gee-0.2.0/geoextract_gee.egg-info/requires.txt +15 -0
  22. geoextract_gee-0.2.0/geoextract_gee.egg-info/top_level.txt +1 -0
  23. geoextract_gee-0.2.0/pyproject.toml +41 -0
  24. geoextract_gee-0.2.0/setup.cfg +4 -0
  25. geoextract_gee-0.2.0/tests/test_backend_split.py +97 -0
  26. geoextract_gee-0.2.0/tests/test_settings.py +51 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bruno Cocitto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: geoextract-gee
3
+ Version: 0.2.0
4
+ Summary: Google Earth Engine backend for geoprepare (geoextract)
5
+ Author: Bruno Cocitto
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Topic :: Scientific/Engineering :: GIS
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: geoprepare>=0.6.331
14
+ Requires-Dist: earthengine-api
15
+ Requires-Dist: google-auth
16
+ Requires-Dist: google-cloud-storage
17
+ Requires-Dist: geopandas
18
+ Requires-Dist: pandas
19
+ Requires-Dist: shapely
20
+ Requires-Dist: arrow
21
+ Requires-Dist: tenacity
22
+ Requires-Dist: tqdm
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest; extra == "dev"
25
+ Requires-Dist: build; extra == "dev"
26
+ Requires-Dist: twine; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # geoextract_gee
30
+
31
+ Google Earth Engine backend extension for `geoprepare` (geoextract).
32
+
33
+ This package provides an optional Google Earth Engine (GEE) execution path for Earth Observation (EO) data extraction. It orchestrates task combinations, processes them using GEE, polls the tasks, and downloads the resulting CSVs from Google Cloud Storage (GCS).
34
+
35
+ ## Prerequisites
36
+
37
+ - **Google Earth Engine Account**: Ensure you have access and a project set up.
38
+ - **Google Cloud Storage**: A GCS bucket is required to export the results before downloading.
39
+ - **Environment**: It is recommended to refer to [installer](https://github.com/ritviksahajpal/installer) for setting up the environment required for `geocif` and `geoprepare`.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install geoextract-gee
45
+ ```
46
+
47
+ This also installs `geoprepare`, which it extends. geoprepare switches to this backend when `use_gee = True` (see Configuration).
48
+
49
+ ## Authentication
50
+
51
+ Set `key` in `[GEE]` to a service account JSON key, as a path relative to geoprepare's metadata folder. Without it (or with `key = none`), the standard Earth Engine sign-in (`ee.Authenticate()`) runs the first time; follow its instructions.
52
+
53
+ ## Configuration
54
+
55
+ Turn the backend on in `[DEFAULT]` of your geoprepare config, and put its settings in a `[GEE]` section:
56
+
57
+ ```ini
58
+ [DEFAULT]
59
+ use_gee = True
60
+ redo = False
61
+
62
+ [GEE]
63
+ project = your-gcp-project-name
64
+ bucket = your-gcs-bucket-name
65
+
66
+ # Optional
67
+ # Service account key, relative to the metadata folder. Default: interactive sign-in.
68
+ key = gee_key.json
69
+ # Compute extra counts and sums per region, for checking results. Costs more EECU. Default False.
70
+ audit_stats = False
71
+ # One GEE task per country instead of one per region (see Architecture). Default False.
72
+ parallel_regions = True
73
+ # Price of one EECU-hour, in dollars, for the cost shown while polling. Default 0.4.
74
+ eecu_h_pricing = 0.4
75
+ # Extract variables that have no GEE handler with the local tiles backend
76
+ # (see "Variables not in Earth Engine" below). Default False.
77
+ fallback_local = False
78
+ # Write GEE outputs to dir_output/gee/ instead of dir_output/, for comparing the
79
+ # two backends. A debugging aid; geomerge reads dir_output. Default False.
80
+ use_subdir = False
81
+ ```
82
+
83
+ Configs from before version 0.2.0 have these settings in `[DEFAULT]` with a `gee_` prefix (`gee_project`, `gee_bucket`, `gee_key`, `gee_audit_stats`, `gee_parallel_regions`, `gee_eecu_h_pricing`, `gee_fallback_local`) and `use_gee_subdir`. They still work, with a warning naming the new setting.
84
+
85
+ ## Supported Datasets
86
+
87
+ The GEE backend currently supports extraction for the following `geoprepare` datasets:
88
+
89
+ | Dataset | Description | Notes |
90
+ |---------|-------------|-------|
91
+ | **AEF** (`aef`) | AlphaEarth Foundations satellite embeddings | Extracted as a multi-year static average. The GEE dataset has different scaling and quantization compared to the raw files from Source Cooperative. |
92
+ | **AgERA5** (`agera5_*`) | Agrometeorological indicators | Extracted from the Climate Engine Community Catalog. Supports `agera5_snow_thickness_lwe` and `agera5_dewpoint` (converted from Kelvin to °C, as in the tiles backend). Only variables the tiles backend also extracts are added, so both backends give the same columns. |
93
+ | **CHIRPS** (`chirps`) | Precipitation data | CHIRPS v3, IMERG-based daily (`UCSB-CHC/CHIRPS/V3/DAILY_SAT`). Arrives in Earth Engine weeks after the CHC server: on 2026-10-11 the last image was 2026-08-31. |
94
+ | **CPC** (`cpc_*`) | Temperature and precipitation | Supports `cpc_tmax`, `cpc_tmin` and `cpc_precip`. Earth Engine stores precipitation in 0.1 mm; it is divided by 10 to give mm. Its current version starts in 2006, so earlier years are empty. |
95
+ | **ESI** (`esi`) | Evaporative Stress Index | ESI data in GEE often has a processing lag. The pipeline extracts the last available data for the given period. Supports `esi_4wk` and `esi_12wk`. |
96
+ | **NDVI** (`ndvi`) | Vegetation index (MODIS) | Because the GEE pipeline pulls daily observations rather than the 8-day composite blocks used by the legacy `tiles` backend, the timing and number of missing observations at the start or end of a time-series will differ. `geomerge` naturally handles linear interpolation of these gaps across year boundaries. |
97
+ | **NSIDC** (`nsidc`) | SMAP L4 soil moisture (`NASA/SMAP/SPL4SMGP/008`) | Supports `nsidc_surface` and `nsidc_rootzone`. |
98
+ | **SoilGrids** (`soilgrids`) | Soil properties | Extracted as a multi-year static dataset. |
99
+ | **VIIRS** (`viirs`) | High-res VIIRS NDVI data | |
100
+
101
+ ## Variables not in Earth Engine
102
+
103
+ Some geoprepare variables have no copy in Earth Engine or its community catalog, so this backend cannot extract them:
104
+
105
+ | Variable | Why |
106
+ |----------|-----|
107
+ | `chirts_era5_tmax`, `chirts_era5_tmin` | Earth Engine has only CHIRTS-daily v1, which ends in 2016. |
108
+ | `chirps_gefs` | CHIRPS-GEFS is not in Earth Engine. |
109
+ | `etref` | NCEP RefET is not in Earth Engine. AgERA5's FAO-56 reference ET is, but it is a different product and geoprepare has no variable for it, so it is not offered as a replacement. |
110
+
111
+ Other variables without a handler (for example `lst`, `fpar`, `ndvi_cdr`) are treated the same way.
112
+
113
+ By default (`fallback_local = False`) the run stops with an error listing these variables, before any GEE task is submitted. With `fallback_local = True`, each one is logged with a warning and extracted by geoprepare's local tiles backend while the GEE tasks run. The local backend reads files that geodownload has already written to `dir_intermed`; geoextract does not download them. If they are missing, the run stops before submitting any task and names the geodownload dataset to run (for example `CHIRTS-ERA5`). Fallback variables are written to `dir_output`, not to `dir_output/gee/`, even with `use_subdir = True`. They are extracted one combination at a time.
114
+
115
+ ## Architecture
116
+
117
+ 1. **Task Submission**: The pipeline extracts data using one of two modes, controlled by `parallel_regions` in `[GEE]`:
118
+ - **Sequential Mode** (`parallel_regions = False`): For each region and variable combination, a separate GEE task is created.
119
+ - **Parallel Batch Mode** (`parallel_regions = True`): All regions within a country are bundled into a single `ee.FeatureCollection` and processed simultaneously by GEE via `reduceRegion` mapping. This submits a single "bulk" GEE task per year/variable, reducing the task queue size and may also avoid quota limits.
120
+ 1. **Note:** For variables requiring heavy reductions (e.g., `nsidc` converting 3-hourly images to daily means), Parallel Batch Mode may cause GEE tasks to take more time to run, time out or run out of memory. In case of error, using Sequential Mode (`parallel_regions = False`) is recommended for these variables.
121
+ 2. **Note:** In Parallel Batch Mode, incremental updates for the current year do not generate partial files like Sequential Mode. Instead, the pipeline automatically re-extracts the entire year and overwrites the local CSVs to ensure seamless data coverage without complex local file merging and different time windows required by each variable.
122
+ 2. **Polling**: The process tracks the submitted tasks and polls Earth Engine until all tasks are marked as `COMPLETED` or `FAILED`. To prevent unexpected billing, polling monitors EECU usage and cancels runaway tasks exceeding defined limits.
123
+ 3. **Download and Format**: Once tasks are complete, the resulting CSVs are downloaded concurrently from Google Cloud Storage using `transfer_manager`. If Parallel Batch Mode was used, the bulk CSV is automatically parsed and split locally into the individual region CSVs expected by `geomerge`.
@@ -0,0 +1,95 @@
1
+ # geoextract_gee
2
+
3
+ Google Earth Engine backend extension for `geoprepare` (geoextract).
4
+
5
+ This package provides an optional Google Earth Engine (GEE) execution path for Earth Observation (EO) data extraction. It orchestrates task combinations, processes them using GEE, polls the tasks, and downloads the resulting CSVs from Google Cloud Storage (GCS).
6
+
7
+ ## Prerequisites
8
+
9
+ - **Google Earth Engine Account**: Ensure you have access and a project set up.
10
+ - **Google Cloud Storage**: A GCS bucket is required to export the results before downloading.
11
+ - **Environment**: It is recommended to refer to [installer](https://github.com/ritviksahajpal/installer) for setting up the environment required for `geocif` and `geoprepare`.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install geoextract-gee
17
+ ```
18
+
19
+ This also installs `geoprepare`, which it extends. geoprepare switches to this backend when `use_gee = True` (see Configuration).
20
+
21
+ ## Authentication
22
+
23
+ Set `key` in `[GEE]` to a service account JSON key, as a path relative to geoprepare's metadata folder. Without it (or with `key = none`), the standard Earth Engine sign-in (`ee.Authenticate()`) runs the first time; follow its instructions.
24
+
25
+ ## Configuration
26
+
27
+ Turn the backend on in `[DEFAULT]` of your geoprepare config, and put its settings in a `[GEE]` section:
28
+
29
+ ```ini
30
+ [DEFAULT]
31
+ use_gee = True
32
+ redo = False
33
+
34
+ [GEE]
35
+ project = your-gcp-project-name
36
+ bucket = your-gcs-bucket-name
37
+
38
+ # Optional
39
+ # Service account key, relative to the metadata folder. Default: interactive sign-in.
40
+ key = gee_key.json
41
+ # Compute extra counts and sums per region, for checking results. Costs more EECU. Default False.
42
+ audit_stats = False
43
+ # One GEE task per country instead of one per region (see Architecture). Default False.
44
+ parallel_regions = True
45
+ # Price of one EECU-hour, in dollars, for the cost shown while polling. Default 0.4.
46
+ eecu_h_pricing = 0.4
47
+ # Extract variables that have no GEE handler with the local tiles backend
48
+ # (see "Variables not in Earth Engine" below). Default False.
49
+ fallback_local = False
50
+ # Write GEE outputs to dir_output/gee/ instead of dir_output/, for comparing the
51
+ # two backends. A debugging aid; geomerge reads dir_output. Default False.
52
+ use_subdir = False
53
+ ```
54
+
55
+ Configs from before version 0.2.0 have these settings in `[DEFAULT]` with a `gee_` prefix (`gee_project`, `gee_bucket`, `gee_key`, `gee_audit_stats`, `gee_parallel_regions`, `gee_eecu_h_pricing`, `gee_fallback_local`) and `use_gee_subdir`. They still work, with a warning naming the new setting.
56
+
57
+ ## Supported Datasets
58
+
59
+ The GEE backend currently supports extraction for the following `geoprepare` datasets:
60
+
61
+ | Dataset | Description | Notes |
62
+ |---------|-------------|-------|
63
+ | **AEF** (`aef`) | AlphaEarth Foundations satellite embeddings | Extracted as a multi-year static average. The GEE dataset has different scaling and quantization compared to the raw files from Source Cooperative. |
64
+ | **AgERA5** (`agera5_*`) | Agrometeorological indicators | Extracted from the Climate Engine Community Catalog. Supports `agera5_snow_thickness_lwe` and `agera5_dewpoint` (converted from Kelvin to °C, as in the tiles backend). Only variables the tiles backend also extracts are added, so both backends give the same columns. |
65
+ | **CHIRPS** (`chirps`) | Precipitation data | CHIRPS v3, IMERG-based daily (`UCSB-CHC/CHIRPS/V3/DAILY_SAT`). Arrives in Earth Engine weeks after the CHC server: on 2026-10-11 the last image was 2026-08-31. |
66
+ | **CPC** (`cpc_*`) | Temperature and precipitation | Supports `cpc_tmax`, `cpc_tmin` and `cpc_precip`. Earth Engine stores precipitation in 0.1 mm; it is divided by 10 to give mm. Its current version starts in 2006, so earlier years are empty. |
67
+ | **ESI** (`esi`) | Evaporative Stress Index | ESI data in GEE often has a processing lag. The pipeline extracts the last available data for the given period. Supports `esi_4wk` and `esi_12wk`. |
68
+ | **NDVI** (`ndvi`) | Vegetation index (MODIS) | Because the GEE pipeline pulls daily observations rather than the 8-day composite blocks used by the legacy `tiles` backend, the timing and number of missing observations at the start or end of a time-series will differ. `geomerge` naturally handles linear interpolation of these gaps across year boundaries. |
69
+ | **NSIDC** (`nsidc`) | SMAP L4 soil moisture (`NASA/SMAP/SPL4SMGP/008`) | Supports `nsidc_surface` and `nsidc_rootzone`. |
70
+ | **SoilGrids** (`soilgrids`) | Soil properties | Extracted as a multi-year static dataset. |
71
+ | **VIIRS** (`viirs`) | High-res VIIRS NDVI data | |
72
+
73
+ ## Variables not in Earth Engine
74
+
75
+ Some geoprepare variables have no copy in Earth Engine or its community catalog, so this backend cannot extract them:
76
+
77
+ | Variable | Why |
78
+ |----------|-----|
79
+ | `chirts_era5_tmax`, `chirts_era5_tmin` | Earth Engine has only CHIRTS-daily v1, which ends in 2016. |
80
+ | `chirps_gefs` | CHIRPS-GEFS is not in Earth Engine. |
81
+ | `etref` | NCEP RefET is not in Earth Engine. AgERA5's FAO-56 reference ET is, but it is a different product and geoprepare has no variable for it, so it is not offered as a replacement. |
82
+
83
+ Other variables without a handler (for example `lst`, `fpar`, `ndvi_cdr`) are treated the same way.
84
+
85
+ By default (`fallback_local = False`) the run stops with an error listing these variables, before any GEE task is submitted. With `fallback_local = True`, each one is logged with a warning and extracted by geoprepare's local tiles backend while the GEE tasks run. The local backend reads files that geodownload has already written to `dir_intermed`; geoextract does not download them. If they are missing, the run stops before submitting any task and names the geodownload dataset to run (for example `CHIRTS-ERA5`). Fallback variables are written to `dir_output`, not to `dir_output/gee/`, even with `use_subdir = True`. They are extracted one combination at a time.
86
+
87
+ ## Architecture
88
+
89
+ 1. **Task Submission**: The pipeline extracts data using one of two modes, controlled by `parallel_regions` in `[GEE]`:
90
+ - **Sequential Mode** (`parallel_regions = False`): For each region and variable combination, a separate GEE task is created.
91
+ - **Parallel Batch Mode** (`parallel_regions = True`): All regions within a country are bundled into a single `ee.FeatureCollection` and processed simultaneously by GEE via `reduceRegion` mapping. This submits a single "bulk" GEE task per year/variable, reducing the task queue size and may also avoid quota limits.
92
+ 1. **Note:** For variables requiring heavy reductions (e.g., `nsidc` converting 3-hourly images to daily means), Parallel Batch Mode may cause GEE tasks to take more time to run, time out or run out of memory. In case of error, using Sequential Mode (`parallel_regions = False`) is recommended for these variables.
93
+ 2. **Note:** In Parallel Batch Mode, incremental updates for the current year do not generate partial files like Sequential Mode. Instead, the pipeline automatically re-extracts the entire year and overwrites the local CSVs to ensure seamless data coverage without complex local file merging and different time windows required by each variable.
94
+ 2. **Polling**: The process tracks the submitted tasks and polls Earth Engine until all tasks are marked as `COMPLETED` or `FAILED`. To prevent unexpected billing, polling monitors EECU usage and cancels runaway tasks exceeding defined limits.
95
+ 3. **Download and Format**: Once tasks are complete, the resulting CSVs are downloaded concurrently from Google Cloud Storage using `transfer_manager`. If Parallel Batch Mode was used, the bulk CSV is automatically parsed and split locally into the individual region CSVs expected by `geomerge`.
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
@@ -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
+ )