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.
- geoextract_gee-0.2.0/LICENSE +21 -0
- geoextract_gee-0.2.0/PKG-INFO +123 -0
- geoextract_gee-0.2.0/README.md +95 -0
- geoextract_gee-0.2.0/geoextract_gee/__init__.py +0 -0
- geoextract_gee-0.2.0/geoextract_gee/asset_manager.py +128 -0
- geoextract_gee-0.2.0/geoextract_gee/datasets/__init__.py +0 -0
- geoextract_gee-0.2.0/geoextract_gee/datasets/aef.py +113 -0
- geoextract_gee-0.2.0/geoextract_gee/datasets/agera5.py +182 -0
- geoextract_gee-0.2.0/geoextract_gee/datasets/chirps.py +168 -0
- geoextract_gee-0.2.0/geoextract_gee/datasets/common.py +136 -0
- geoextract_gee-0.2.0/geoextract_gee/datasets/cpc.py +181 -0
- geoextract_gee-0.2.0/geoextract_gee/datasets/esi.py +169 -0
- geoextract_gee-0.2.0/geoextract_gee/datasets/ndvi.py +201 -0
- geoextract_gee-0.2.0/geoextract_gee/datasets/nsidc.py +195 -0
- geoextract_gee-0.2.0/geoextract_gee/datasets/soilgrids.py +98 -0
- geoextract_gee-0.2.0/geoextract_gee/datasets/viirs.py +185 -0
- geoextract_gee-0.2.0/geoextract_gee/extract_EO_gee.py +932 -0
- geoextract_gee-0.2.0/geoextract_gee.egg-info/PKG-INFO +123 -0
- geoextract_gee-0.2.0/geoextract_gee.egg-info/SOURCES.txt +24 -0
- geoextract_gee-0.2.0/geoextract_gee.egg-info/dependency_links.txt +1 -0
- geoextract_gee-0.2.0/geoextract_gee.egg-info/requires.txt +15 -0
- geoextract_gee-0.2.0/geoextract_gee.egg-info/top_level.txt +1 -0
- geoextract_gee-0.2.0/pyproject.toml +41 -0
- geoextract_gee-0.2.0/setup.cfg +4 -0
- geoextract_gee-0.2.0/tests/test_backend_split.py +97 -0
- 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
|
|
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
|
+
)
|