redlem-force 0.13.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redlem_force/__init__.py +19 -0
- redlem_force/cells.py +317 -0
- redlem_force/config.py +142 -0
- redlem_force/dates.py +50 -0
- redlem_force/ee_utils.py +150 -0
- redlem_force/extract.py +624 -0
- redlem_force/figures.py +469 -0
- redlem_force/files.py +186 -0
- redlem_force/geometry.py +236 -0
- redlem_force/grid_cells.py +207 -0
- redlem_force/grids.py +124 -0
- redlem_force/met.py +40 -0
- redlem_force/prism_cells.py +109 -0
- redlem_force/quota.py +173 -0
- redlem_force-0.13.1.dist-info/METADATA +399 -0
- redlem_force-0.13.1.dist-info/RECORD +19 -0
- redlem_force-0.13.1.dist-info/WHEEL +5 -0
- redlem_force-0.13.1.dist-info/licenses/LICENSE +202 -0
- redlem_force-0.13.1.dist-info/top_level.txt +1 -0
redlem_force/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""redlem-force: DLEM forcing-data extraction from Google Earth Engine."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .cells import select_urma_cells
|
|
6
|
+
from .config import RunConfig
|
|
7
|
+
from .extract import extract_urma
|
|
8
|
+
from .figures import plot_cells
|
|
9
|
+
from .prism_cells import select_prism_cells
|
|
10
|
+
from .quota import (QuotaExceededError, QuotaUnavailableError,
|
|
11
|
+
estimate_eecu_hours, quota_status)
|
|
12
|
+
|
|
13
|
+
logging.getLogger('redlem_force').addHandler(logging.NullHandler())
|
|
14
|
+
|
|
15
|
+
__version__ = "0.13.1"
|
|
16
|
+
__all__ = ['RunConfig', 'extract_urma', 'select_urma_cells',
|
|
17
|
+
'select_prism_cells', 'plot_cells',
|
|
18
|
+
'quota_status', 'estimate_eecu_hours', 'QuotaExceededError',
|
|
19
|
+
'QuotaUnavailableError']
|
redlem_force/cells.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""URMA cell selection: native reservoir polygon -> the URMA grid cells used
|
|
2
|
+
for the met variables.
|
|
3
|
+
|
|
4
|
+
Everything is computed locally on the URMA grid of the reference image
|
|
5
|
+
(redlem_force.grids: the first URMA daily image of 2026). Earth Engine is
|
|
6
|
+
used for two things only:
|
|
7
|
+
* the grid definition (CRS + affine transform), one small request
|
|
8
|
+
* rtma_elev and rtma_water_mask read ONCE as arrays on that same grid
|
|
9
|
+
(they sit on the same lattice; checked 2026-10-13), one value
|
|
10
|
+
per URMA cell
|
|
11
|
+
No grid-cell asset and no per-reservoir Earth Engine requests.
|
|
12
|
+
|
|
13
|
+
Selection rule (same logic as urma_cells.py; cell values are now the pixel
|
|
14
|
+
values of the cell itself):
|
|
15
|
+
* candidate cells = URMA cells the polygon intersects (sharing area;
|
|
16
|
+
touching only at an edge / corner does not count)
|
|
17
|
+
* water-mask flag: a column you pass, or the rule max(water mask of the
|
|
18
|
+
candidate cells) == 1 (urma_cells.py used the max at 100 m over the
|
|
19
|
+
polygon, i.e. the cells it intersects)
|
|
20
|
+
* flag 1 -> candidates whose water mask is 1; if there are none, the rule
|
|
21
|
+
below (cell_method = water_mask_fallback)
|
|
22
|
+
* otherwise -> candidates with overlap area >= the median overlap, then of
|
|
23
|
+
those, the ones with elevation <= their median elevation
|
|
24
|
+
(overlap_elevation). Medians: numpy median of the candidates' values.
|
|
25
|
+
* output geometry = union of the selected cells (exact pixel squares)
|
|
26
|
+
|
|
27
|
+
Output columns:
|
|
28
|
+
<id_col> str
|
|
29
|
+
cell_geometry WKB, EPSG:4326
|
|
30
|
+
n_cells cells selected
|
|
31
|
+
n_touching candidate cells (intersecting the reservoir)
|
|
32
|
+
wm_value flag value used (rule: max water mask of the candidates;
|
|
33
|
+
null = no water-mask data there)
|
|
34
|
+
cell_method water_mask | water_mask_fallback | overlap_elevation
|
|
35
|
+
cell_elev_m mean rtma_elev of the selected cells (-> ELEV_M)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import logging
|
|
41
|
+
import os
|
|
42
|
+
|
|
43
|
+
import ee
|
|
44
|
+
import numpy as np
|
|
45
|
+
import polars as pl
|
|
46
|
+
import shapely
|
|
47
|
+
|
|
48
|
+
from . import grids
|
|
49
|
+
from .config import ELEV_ID, WM_ID, RunConfig
|
|
50
|
+
from .ee_utils import init_ee, with_retries
|
|
51
|
+
from .geometry import load_geometries, reproject
|
|
52
|
+
from .grid_cells import Grid, cell_geometry
|
|
53
|
+
|
|
54
|
+
log = logging.getLogger('redlem_force')
|
|
55
|
+
|
|
56
|
+
CELL_SCHEMA = {
|
|
57
|
+
'cell_geometry': pl.Binary,
|
|
58
|
+
'n_cells': pl.Int64,
|
|
59
|
+
'n_touching': pl.Int64,
|
|
60
|
+
'wm_value': pl.Float64,
|
|
61
|
+
'cell_method': pl.Utf8,
|
|
62
|
+
'cell_elev_m': pl.Float64,
|
|
63
|
+
}
|
|
64
|
+
ROWS_PER_REQUEST = 256 # computePixels tile height (rows of the grid)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Per-cell values on the URMA grid (one download per run)
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
class CellValues:
|
|
71
|
+
"""rtma_elev / rtma_water_mask per URMA cell, as arrays indexed by the
|
|
72
|
+
reference grid's (col, row). NaN = no data."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, grid: Grid, col0, row0, elev, wm):
|
|
75
|
+
self.grid, self.col0, self.row0 = grid, col0, row0
|
|
76
|
+
self.elev, self.wm = elev, wm # [row - row0, col - col0]
|
|
77
|
+
|
|
78
|
+
def lookup(self, cols, rows):
|
|
79
|
+
r = np.asarray(rows) - self.row0
|
|
80
|
+
c = np.asarray(cols) - self.col0
|
|
81
|
+
ok = (r >= 0) & (r < self.elev.shape[0]) & (c >= 0) & \
|
|
82
|
+
(c < self.elev.shape[1])
|
|
83
|
+
e = np.full(len(r), np.nan)
|
|
84
|
+
w = np.full(len(r), np.nan)
|
|
85
|
+
e[ok] = self.elev[r[ok], c[ok]]
|
|
86
|
+
w[ok] = self.wm[r[ok], c[ok]]
|
|
87
|
+
return e, w, ok
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _value_image():
|
|
91
|
+
elev = ee.Image(ELEV_ID).select(0)
|
|
92
|
+
wm = ee.Image(WM_ID).select(0)
|
|
93
|
+
# data values, and 1/0 masks so missing pixels can be told apart
|
|
94
|
+
return ee.Image.cat([
|
|
95
|
+
elev.unmask(0, False).toFloat().rename('elev'),
|
|
96
|
+
elev.mask().unmask(0, False).toFloat().rename('elev_ok'),
|
|
97
|
+
wm.unmask(0, False).toFloat().rename('wm'),
|
|
98
|
+
wm.mask().unmask(0, False).toFloat().rename('wm_ok'),
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fetch_cell_values(config: RunConfig) -> CellValues:
|
|
103
|
+
"""Read rtma_elev / rtma_water_mask on the URMA reference grid, over the
|
|
104
|
+
reference image's pixel extent, in tiles of ROWS_PER_REQUEST rows."""
|
|
105
|
+
info = grids.grid_info('urma', config)
|
|
106
|
+
grid, w, h = info['grid'], info['width'], info['height']
|
|
107
|
+
col0, row0 = info['col0'], info['row0']
|
|
108
|
+
if not w or not h:
|
|
109
|
+
raise RuntimeError(f'URMA reference image has no dimensions: {info}')
|
|
110
|
+
init_ee(config.project, config.base_url)
|
|
111
|
+
img = _value_image()
|
|
112
|
+
crs = grid.ee_crs or grid.crs # Earth Engine reads its own text
|
|
113
|
+
elev = np.empty((h, w), dtype=np.float64)
|
|
114
|
+
wm = np.empty((h, w), dtype=np.float64)
|
|
115
|
+
for r in range(0, h, ROWS_PER_REQUEST):
|
|
116
|
+
n = min(ROWS_PER_REQUEST, h - r)
|
|
117
|
+
req = {
|
|
118
|
+
'expression': img,
|
|
119
|
+
'fileFormat': 'NUMPY_NDARRAY',
|
|
120
|
+
'grid': {
|
|
121
|
+
'dimensions': {'width': w, 'height': n},
|
|
122
|
+
'affineTransform': {
|
|
123
|
+
'scaleX': grid.a, 'shearX': 0,
|
|
124
|
+
'translateX': grid.c + col0 * grid.a,
|
|
125
|
+
'shearY': 0, 'scaleY': grid.e,
|
|
126
|
+
'translateY': grid.f + (row0 + r) * grid.e},
|
|
127
|
+
'crsWkt' if crs.lstrip().upper().startswith(
|
|
128
|
+
('PROJCS', 'GEOGCS', 'PROJCRS', 'GEOGCRS'))
|
|
129
|
+
else 'crsCode': crs,
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
arr = with_retries(lambda: ee.data.computePixels(req),
|
|
133
|
+
config.max_retries, config.retry_wait_secs)
|
|
134
|
+
elev[r:r + n] = np.where(arr['elev_ok'] > 0, arr['elev'], np.nan)
|
|
135
|
+
wm[r:r + n] = np.where(arr['wm_ok'] > 0, arr['wm'], np.nan)
|
|
136
|
+
log.info('URMA cell values: %d x %d cells; elevation for %d, water mask '
|
|
137
|
+
'for %d (%d water cells)', w, h, int(np.isfinite(elev).sum()),
|
|
138
|
+
int(np.isfinite(wm).sum()), int((wm == 1).sum()))
|
|
139
|
+
return CellValues(grid, col0, row0, elev, wm)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Selection (local)
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
def candidates(geom, grid: Grid):
|
|
146
|
+
"""(cols, rows, overlap areas) of cells geom intersects (grid CRS)."""
|
|
147
|
+
minx, miny, maxx, maxy = geom.bounds
|
|
148
|
+
c0, r0 = grid.col_row(minx, maxy)
|
|
149
|
+
c1, r1 = grid.col_row(maxx, miny)
|
|
150
|
+
cc, rr = np.meshgrid(np.arange(min(c0, c1), max(c0, c1) + 1),
|
|
151
|
+
np.arange(min(r0, r1), max(r0, r1) + 1))
|
|
152
|
+
cc, rr = cc.ravel(), rr.ravel()
|
|
153
|
+
sq = grid.squares(cc, rr)
|
|
154
|
+
shapely.prepare(geom)
|
|
155
|
+
hit = shapely.intersects(geom, sq) & ~shapely.touches(geom, sq)
|
|
156
|
+
cc, rr, sq = cc[hit], rr[hit], sq[hit]
|
|
157
|
+
area = shapely.area(shapely.intersection(geom, sq))
|
|
158
|
+
keep = area > 0
|
|
159
|
+
return cc[keep], rr[keep], area[keep]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def select_one(geom, values: CellValues, flag=None):
|
|
163
|
+
"""Selection for one polygon in the grid CRS. flag: 0/1 from a column,
|
|
164
|
+
or None for the rule. Returns a dict of CELL_SCHEMA values (geometry in
|
|
165
|
+
the grid CRS, not yet WKB), plus 'cells': the selected (col, row)
|
|
166
|
+
pairs when any were selected."""
|
|
167
|
+
grid = values.grid
|
|
168
|
+
cols, rows, area = candidates(geom, grid)
|
|
169
|
+
n_touch = len(cols)
|
|
170
|
+
if n_touch == 0:
|
|
171
|
+
return {'geom': None, 'n_cells': 0, 'n_touching': 0,
|
|
172
|
+
'wm_value': None if flag is None else float(flag),
|
|
173
|
+
'cell_method': None, 'cell_elev_m': None}
|
|
174
|
+
elev, wm, _ = values.lookup(cols, rows)
|
|
175
|
+
if flag is None:
|
|
176
|
+
wm_value = float(np.nanmax(wm)) if np.isfinite(wm).any() else None
|
|
177
|
+
else:
|
|
178
|
+
wm_value = float(flag)
|
|
179
|
+
use_wm = wm_value == 1
|
|
180
|
+
|
|
181
|
+
water = wm == 1
|
|
182
|
+
if use_wm and water.any():
|
|
183
|
+
sel, method = water, 'water_mask'
|
|
184
|
+
else:
|
|
185
|
+
method = 'water_mask_fallback' if use_wm else 'overlap_elevation'
|
|
186
|
+
sel = area >= np.median(area)
|
|
187
|
+
e = elev[sel]
|
|
188
|
+
if np.isfinite(e).any():
|
|
189
|
+
cut = np.median(e[np.isfinite(e)])
|
|
190
|
+
sub = np.isfinite(elev) & (elev <= cut)
|
|
191
|
+
sel = sel & sub
|
|
192
|
+
n = int(sel.sum())
|
|
193
|
+
if n == 0:
|
|
194
|
+
return {'geom': None, 'n_cells': 0, 'n_touching': n_touch,
|
|
195
|
+
'wm_value': wm_value, 'cell_method': method,
|
|
196
|
+
'cell_elev_m': None}
|
|
197
|
+
g = cell_geometry(list(zip(cols[sel].tolist(), rows[sel].tolist())),
|
|
198
|
+
grid) # valid, or raises
|
|
199
|
+
e = elev[sel]
|
|
200
|
+
return {'geom': g, 'cells': list(zip(cols[sel].tolist(),
|
|
201
|
+
rows[sel].tolist())),
|
|
202
|
+
'n_cells': n, 'n_touching': n_touch,
|
|
203
|
+
'wm_value': wm_value, 'cell_method': method,
|
|
204
|
+
'cell_elev_m': float(np.nanmean(e)) if np.isfinite(e).any()
|
|
205
|
+
else None}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _log_summary(df: pl.DataFrame, id_col: str):
|
|
209
|
+
zero = df.filter(pl.col('cell_geometry').is_null())[id_col].to_list()
|
|
210
|
+
if zero:
|
|
211
|
+
log.warning('%d reservoirs got zero cells: %s', len(zero), zero[:20])
|
|
212
|
+
fb = df.filter(pl.col('cell_method') == 'water_mask_fallback')[id_col]
|
|
213
|
+
if len(fb):
|
|
214
|
+
log.warning('%d water-mask reservoirs had no water cells and used the '
|
|
215
|
+
'overlap/elevation rule: %s', len(fb), fb.to_list()[:20])
|
|
216
|
+
log.info('cell_method counts: %s', df['cell_method'].value_counts().rows())
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# Public API
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
def _select_cells_frame(native: pl.DataFrame, *, id_col: str, crs,
|
|
223
|
+
config: RunConfig, water_mask_col: str | None = None,
|
|
224
|
+
geometry_col: str = 'geometry',
|
|
225
|
+
values: CellValues | None = None) -> pl.DataFrame:
|
|
226
|
+
"""URMA cell selection for reservoirs given as a polars DataFrame.
|
|
227
|
+
|
|
228
|
+
native : id_col and geometry_col (WKB or WKT, in `crs`).
|
|
229
|
+
water_mask_col : Boolean column (no nulls): True -> water-mask cells,
|
|
230
|
+
False -> overlap/elevation rule. None (default) -> the
|
|
231
|
+
rule (max water mask of the intersecting cells == 1).
|
|
232
|
+
values : CellValues to reuse (default: fetched here).
|
|
233
|
+
Returns id_col + CELL_SCHEMA, sorted by id.
|
|
234
|
+
"""
|
|
235
|
+
gs = load_geometries(native, id_col, geometry_col, crs,
|
|
236
|
+
label='native reservoir')
|
|
237
|
+
if water_mask_col is None:
|
|
238
|
+
flags = [None] * len(gs.ids)
|
|
239
|
+
else:
|
|
240
|
+
if water_mask_col not in native.columns:
|
|
241
|
+
raise KeyError(f'water mask column {water_mask_col!r} not in '
|
|
242
|
+
f'{native.columns}')
|
|
243
|
+
wm = native.get_column(water_mask_col)
|
|
244
|
+
if wm.dtype != pl.Boolean:
|
|
245
|
+
raise TypeError(f'{water_mask_col!r} must be Boolean, got '
|
|
246
|
+
f'{wm.dtype}')
|
|
247
|
+
if wm.null_count():
|
|
248
|
+
raise ValueError(f'{wm.null_count()} null values in '
|
|
249
|
+
f'{water_mask_col!r}')
|
|
250
|
+
flags = [int(w) for w in wm.to_list()]
|
|
251
|
+
|
|
252
|
+
values = values or fetch_cell_values(config)
|
|
253
|
+
grid = values.grid
|
|
254
|
+
polys = reproject(gs.geoms, gs.crs, grid.crs)
|
|
255
|
+
rows = [select_one(g, values, f) for g, f in zip(polys, flags)]
|
|
256
|
+
geoms = np.array([r['geom'] for r in rows], dtype=object)
|
|
257
|
+
ok = np.array([g is not None for g in geoms])
|
|
258
|
+
wkb = [None] * len(geoms)
|
|
259
|
+
if ok.any():
|
|
260
|
+
wgs = reproject(geoms[ok], grid.crs, 'EPSG:4326')
|
|
261
|
+
for k, g in zip(np.flatnonzero(ok), wgs):
|
|
262
|
+
wkb[k] = shapely.to_wkb(g)
|
|
263
|
+
out = pl.DataFrame({
|
|
264
|
+
id_col: gs.ids,
|
|
265
|
+
'cell_geometry': wkb,
|
|
266
|
+
**{c: [r[c] for r in rows] for c in CELL_SCHEMA
|
|
267
|
+
if c != 'cell_geometry'},
|
|
268
|
+
}, schema={id_col: pl.Utf8, **CELL_SCHEMA}).sort(id_col)
|
|
269
|
+
log.info('select_urma_cells: %d reservoirs, water mask from %s',
|
|
270
|
+
out.height, water_mask_col or 'the rule')
|
|
271
|
+
_log_summary(out, id_col)
|
|
272
|
+
return out
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def select_urma_cells(geometry, *, id_col: str, config: RunConfig,
|
|
276
|
+
layer: str | None = None,
|
|
277
|
+
sample: int | None = None, sample_seed: int = 1,
|
|
278
|
+
crs=None, geometry_col: str = 'geometry',
|
|
279
|
+
water_mask_col: str | None = None):
|
|
280
|
+
"""URMA cell selection (URMA met variables).
|
|
281
|
+
|
|
282
|
+
geometry : the reservoir polygons -- a geopandas GeoDataFrame (e.g.
|
|
283
|
+
gpd.read_file of a GeoPackage, or a database read), a path
|
|
284
|
+
to a .shp / .gpkg, or a polars DataFrame with id_col and
|
|
285
|
+
WKB/WKT geometry_col (then `crs` is required).
|
|
286
|
+
|
|
287
|
+
GeoDataFrame / path -> GeoDataFrame: every original column + N_CELLS, ELEV_M (mean elevation of the
|
|
288
|
+
selected cells, m), WATER_MASK (1 = water-mask rule, 0 =
|
|
289
|
+
overlap/elevation rule);
|
|
290
|
+
geometry = the selected cells, in the input CRS. Nothing is written to
|
|
291
|
+
disk; saving it is up to the caller (see scripts/run_*_cells.py).
|
|
292
|
+
polars DataFrame -> polars DataFrame: id_col, cell_geometry (WKB,
|
|
293
|
+
EPSG:4326), n_cells, n_touching,
|
|
294
|
+
wm_value, cell_method, cell_elev_m.
|
|
295
|
+
|
|
296
|
+
layer : layer to read from a multi-layer GeoPackage (path input)
|
|
297
|
+
sample : random N reservoirs, for a trial (path / GeoDataFrame)
|
|
298
|
+
water_mask_col : existing 0/1 / Boolean column for the flag; None = the
|
|
299
|
+
rule (max water mask of the intersecting cells == 1)
|
|
300
|
+
"""
|
|
301
|
+
if isinstance(geometry, pl.DataFrame):
|
|
302
|
+
if crs is None:
|
|
303
|
+
raise ValueError('crs is required when geometry is a DataFrame')
|
|
304
|
+
return _select_cells_frame(geometry, id_col=id_col, crs=crs,
|
|
305
|
+
config=config,
|
|
306
|
+
water_mask_col=water_mask_col,
|
|
307
|
+
geometry_col=geometry_col)
|
|
308
|
+
from .files import is_geodataframe, urma_cells
|
|
309
|
+
if not (is_geodataframe(geometry)
|
|
310
|
+
or isinstance(geometry, (str, os.PathLike))):
|
|
311
|
+
raise TypeError('geometry must be a GeoDataFrame, a file path (.shp / '
|
|
312
|
+
'.gpkg) or a polars DataFrame, got '
|
|
313
|
+
f'{type(geometry).__name__}')
|
|
314
|
+
return urma_cells(geometry, id_col=id_col, config=config,
|
|
315
|
+
layer=layer,
|
|
316
|
+
water_mask_col=water_mask_col, sample=sample,
|
|
317
|
+
sample_seed=sample_seed)
|
redlem_force/config.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Dataset IDs, band/column maps, and the user-facing run configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
# ---------------------------------------------------------------------------
|
|
8
|
+
# Earth Engine assets
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
URMA_ID = 'projects/climate-engine-pro/assets/noaa-urma/daily'
|
|
11
|
+
PRISM_800_ID = 'projects/sat-io/open-datasets/OREGONSTATE/PRISM_800_DAILY'
|
|
12
|
+
PRISM_4K_ID = 'OREGONSTATE/PRISM/ANd'
|
|
13
|
+
|
|
14
|
+
# Per-cell values for URMA cell selection, read on the URMA grid.
|
|
15
|
+
# rtma_elev: same lattice as the URMA grid (checked 2026-10-13).
|
|
16
|
+
# Water mask: URMA ancillary asset (public); its grid not yet checked here.
|
|
17
|
+
ELEV_ID = 'projects/bor-evap/assets/rtma_elev'
|
|
18
|
+
WM_ID = 'projects/climate-engine-pro/assets/noaa-urma/ancillary/water_mask'
|
|
19
|
+
|
|
20
|
+
HIGH_VOLUME_URL = 'https://earthengine-highvolume.googleapis.com'
|
|
21
|
+
|
|
22
|
+
# The ONLY source of grid information (redlem_force.grids): the reference
|
|
23
|
+
# image of each source as (collection, band, year of the first image by
|
|
24
|
+
# system:index, or None = first image of the collection).
|
|
25
|
+
GRID_REFERENCE = {
|
|
26
|
+
'urma': (URMA_ID, 'TMAX', 2026),
|
|
27
|
+
'prism800': (PRISM_800_ID, 'ppt', None),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Property name used for the reservoir ID inside Earth Engine requests.
|
|
31
|
+
# The user's own ID column name is restored on output.
|
|
32
|
+
ID_PROP = 'dlem_id'
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# URMA met variables (same bands, reducers and output names as
|
|
36
|
+
# urma_download_v2.py)
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
MEAN_BANDS = ['PRES', 'TMIN', 'TMAX', 'vs2m', 'SRAD_TCDC', 'vpd', 'SPFH',
|
|
39
|
+
'ETo', 'ETr']
|
|
40
|
+
MEDIAN_BANDS = ['WDIR']
|
|
41
|
+
|
|
42
|
+
# reduceRegions output name -> output column name. Output names follow the
|
|
43
|
+
# DLEM variable config (key = output name, band_name = source band):
|
|
44
|
+
# tmax TMAX degC tmin TMIN degC vpd vpd kpa (derived) vs2m m/s
|
|
45
|
+
# (derived from WIND) srad SRAD_TCDC wm2 etr ETr mm/day eto ETo
|
|
46
|
+
# mm/day th WDIR deg (median) sph SPFH kg/kg pres PRES pa
|
|
47
|
+
# pr (mm) is the precip column: PRISM 800 m ppt, falling back to PRISM 4 km
|
|
48
|
+
# ppt, then URMA PCP (pr_source says which).
|
|
49
|
+
URMA_OUTPUT = {
|
|
50
|
+
'TMAX_mean': 'tmax',
|
|
51
|
+
'TMIN_mean': 'tmin',
|
|
52
|
+
'vpd_mean': 'vpd',
|
|
53
|
+
'vs2m_mean': 'vs2m',
|
|
54
|
+
'SRAD_TCDC_mean': 'srad',
|
|
55
|
+
'ETr_mean': 'etr',
|
|
56
|
+
'ETo_mean': 'eto',
|
|
57
|
+
'WDIR_median': 'th',
|
|
58
|
+
'SPFH_mean': 'sph',
|
|
59
|
+
'PRES_mean': 'pres',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Precip fallback chain, in priority order: (name, collection, band).
|
|
64
|
+
# All three are matched by date label (system:index == YYYYMMDD), never by
|
|
65
|
+
# filterDate: the 4 km collection stamps images at 12:00 UTC on the day
|
|
66
|
+
# BEFORE their label. Verified: 4 km vs 800 m corr 0.998 when matched by
|
|
67
|
+
# label, ~0.17 when off by a day.
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
PRECIP_SOURCES = (
|
|
70
|
+
('prism800', PRISM_800_ID, 'ppt'),
|
|
71
|
+
('prism4k', PRISM_4K_ID, 'ppt'),
|
|
72
|
+
('urma', URMA_ID, 'PCP'),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Output columns, in order. The ID column takes the user's name.
|
|
76
|
+
FLOAT_COLS = ['pr'] + list(URMA_OUTPUT.values())
|
|
77
|
+
OUTPUT_COLS_AFTER_ID = (['date'] + FLOAT_COLS
|
|
78
|
+
+ ['pr_source', 'urma_status', 'processed_date'])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class RunConfig:
|
|
83
|
+
"""Settings for one run.
|
|
84
|
+
|
|
85
|
+
project : Google Cloud project used for Earth Engine.
|
|
86
|
+
n_workers : parallel worker processes. 1 = run in this
|
|
87
|
+
process (useful for debugging / Spyder).
|
|
88
|
+
base_url : Earth Engine endpoint. Default is the high-volume
|
|
89
|
+
endpoint used by urma_download_v2.py. None = the
|
|
90
|
+
standard endpoint.
|
|
91
|
+
tile_scale : reduceRegions tileScale. Raise to 2 or 4 only on
|
|
92
|
+
an Earth Engine memory error.
|
|
93
|
+
features_per_request : max reservoirs per extraction request.
|
|
94
|
+
days_per_request : consecutive dates stacked into one request (all
|
|
95
|
+
days of a block as bands of one image). 5 was
|
|
96
|
+
benchmarked (stacking 5 days: ~1.35x faster for
|
|
97
|
+
URMA met, ~2.7x for precip); larger blocks are
|
|
98
|
+
NOT yet benchmarked.
|
|
99
|
+
max_request_bytes : max geometry bytes (GeoJSON) per extraction
|
|
100
|
+
request. Earth Engine rejects requests over
|
|
101
|
+
10,485,760 bytes ("Request payload size exceeds the
|
|
102
|
+
limit", seen with 1000 detailed reservoirs); the
|
|
103
|
+
4 MB default leaves room for request overhead.
|
|
104
|
+
max_retries : attempts per Earth Engine call / download.
|
|
105
|
+
retry_wait_secs : base wait for exponential backoff.
|
|
106
|
+
download_timeout_secs: HTTP timeout for CSV downloads.
|
|
107
|
+
check_quota : before extracting, estimate the run's compute
|
|
108
|
+
and stop if it exceeds the project's remaining
|
|
109
|
+
monthly Earth Engine quota (needs
|
|
110
|
+
google-cloud-monitoring).
|
|
111
|
+
eecu_s_per_reservoir_day : cost estimate used by that check. Default
|
|
112
|
+
0.012 = measured ~0.0102 (2026-10-15, URMA met +
|
|
113
|
+
PRISM precip, 5-day blocks), rounded up.
|
|
114
|
+
quota_fraction : stop if the estimate exceeds this fraction of the
|
|
115
|
+
remaining quota (1.0 = all of it).
|
|
116
|
+
"""
|
|
117
|
+
project: str
|
|
118
|
+
n_workers: int = 8
|
|
119
|
+
base_url: str | None = HIGH_VOLUME_URL
|
|
120
|
+
tile_scale: float = 1
|
|
121
|
+
features_per_request: int = 1000
|
|
122
|
+
days_per_request: int = 5
|
|
123
|
+
max_request_bytes: int = 4_000_000
|
|
124
|
+
max_retries: int = 5
|
|
125
|
+
retry_wait_secs: float = 10.0
|
|
126
|
+
download_timeout_secs: float = 600.0
|
|
127
|
+
check_quota: bool = True
|
|
128
|
+
eecu_s_per_reservoir_day: float = 0.012
|
|
129
|
+
quota_fraction: float = 1.0
|
|
130
|
+
|
|
131
|
+
def __post_init__(self):
|
|
132
|
+
if not self.project:
|
|
133
|
+
raise ValueError('project is required')
|
|
134
|
+
for name in ('n_workers', 'features_per_request', 'days_per_request',
|
|
135
|
+
'max_retries',
|
|
136
|
+
'max_request_bytes'):
|
|
137
|
+
if int(getattr(self, name)) < 1:
|
|
138
|
+
raise ValueError(f'{name} must be >= 1')
|
|
139
|
+
if self.eecu_s_per_reservoir_day <= 0:
|
|
140
|
+
raise ValueError('eecu_s_per_reservoir_day must be > 0')
|
|
141
|
+
if not 0 < self.quota_fraction <= 1:
|
|
142
|
+
raise ValueError('quota_fraction must be in (0, 1]')
|
redlem_force/dates.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Date input handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_date(value) -> dt.date:
|
|
9
|
+
"""Accept datetime.date, datetime.datetime, 'YYYY-MM-DD' or 'YYYYMMDD'."""
|
|
10
|
+
if isinstance(value, dt.datetime):
|
|
11
|
+
return value.date()
|
|
12
|
+
if isinstance(value, dt.date):
|
|
13
|
+
return value
|
|
14
|
+
if isinstance(value, str):
|
|
15
|
+
s = value.strip()
|
|
16
|
+
for fmt in ('%Y-%m-%d', '%Y%m%d'):
|
|
17
|
+
try:
|
|
18
|
+
return dt.datetime.strptime(s, fmt).date()
|
|
19
|
+
except ValueError:
|
|
20
|
+
pass
|
|
21
|
+
raise ValueError(f'Unrecognized date {value!r}; use YYYY-MM-DD, YYYYMMDD '
|
|
22
|
+
'or datetime.date')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_dates(dates=None, start=None, end=None) -> list[dt.date]:
|
|
26
|
+
"""Sorted, de-duplicated list of dates.
|
|
27
|
+
|
|
28
|
+
Either `dates` (one date or an iterable of dates), or `start` and `end`
|
|
29
|
+
(both INCLUSIVE). Not both.
|
|
30
|
+
"""
|
|
31
|
+
if dates is not None and (start is not None or end is not None):
|
|
32
|
+
raise ValueError('Pass either dates or start/end, not both')
|
|
33
|
+
if dates is not None:
|
|
34
|
+
items = [dates] if isinstance(dates, (str, dt.date)) else list(dates)
|
|
35
|
+
out = sorted({to_date(d) for d in items})
|
|
36
|
+
elif start is not None and end is not None:
|
|
37
|
+
s, e = to_date(start), to_date(end)
|
|
38
|
+
if e < s:
|
|
39
|
+
raise ValueError(f'end {e} is before start {s}')
|
|
40
|
+
out = [s + dt.timedelta(days=i) for i in range((e - s).days + 1)]
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError('Pass dates, or both start and end')
|
|
43
|
+
if not out:
|
|
44
|
+
raise ValueError('No dates given')
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def label(d: dt.date) -> str:
|
|
49
|
+
"""Date label used by every collection's system:index: 'YYYYMMDD'."""
|
|
50
|
+
return d.strftime('%Y%m%d')
|
redlem_force/ee_utils.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Earth Engine session, retries, downloads and the worker pool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import logging
|
|
7
|
+
import multiprocessing
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import ee
|
|
12
|
+
import polars as pl
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from .config import ID_PROP
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger('redlem_force')
|
|
18
|
+
|
|
19
|
+
# Substrings that mark an error worth retrying as-is (quota, transient server
|
|
20
|
+
# or network trouble). Network timeouts are caught by type below.
|
|
21
|
+
_RETRYABLE = ('Too Many Requests', '429', 'Quota exceeded',
|
|
22
|
+
'Service Unavailable', '503', '502', 'Internal error',
|
|
23
|
+
'Connection reset', 'Connection aborted')
|
|
24
|
+
|
|
25
|
+
# Substrings that mean the REQUEST IS TOO BIG. Retrying the same request
|
|
26
|
+
# will fail the same way; the remedy is a smaller request.
|
|
27
|
+
_TOO_BIG = ('Computation timed out', 'User memory limit exceeded',
|
|
28
|
+
'Too many concurrent aggregations', 'payload size',
|
|
29
|
+
'Request payload')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_too_big(exc: Exception) -> bool:
|
|
33
|
+
msg = str(exc)
|
|
34
|
+
return any(tok in msg for tok in _TOO_BIG)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def solve_split(fn, items: list):
|
|
38
|
+
"""Call fn(items). If it fails because the request is too big, split
|
|
39
|
+
items in half and recurse, down to single items.
|
|
40
|
+
|
|
41
|
+
Returns (results, failures): results is the concatenation of fn's list
|
|
42
|
+
results; failures is [(item, message)] for items that still failed.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
return fn(items), []
|
|
46
|
+
except Exception as e:
|
|
47
|
+
msg = f'{type(e).__name__}: {e}'
|
|
48
|
+
if len(items) > 1 and is_too_big(e):
|
|
49
|
+
mid = len(items) // 2
|
|
50
|
+
r1, f1 = solve_split(fn, items[:mid])
|
|
51
|
+
r2, f2 = solve_split(fn, items[mid:])
|
|
52
|
+
return r1 + r2, f1 + f2
|
|
53
|
+
return [], [(it, msg) for it in items]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_retryable(exc: Exception) -> bool:
|
|
57
|
+
if isinstance(exc, (requests.ConnectionError, requests.Timeout)):
|
|
58
|
+
return True
|
|
59
|
+
if isinstance(exc, requests.HTTPError) and exc.response is not None:
|
|
60
|
+
return exc.response.status_code == 429 or exc.response.status_code >= 500
|
|
61
|
+
msg = str(exc)
|
|
62
|
+
return any(tok in msg for tok in _RETRYABLE)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def with_retries(fn, max_retries: int = 5, wait: float = 10.0):
|
|
66
|
+
"""Call fn(); on a retryable error wait (exponential backoff with jitter)
|
|
67
|
+
and try again, up to max_retries attempts in total."""
|
|
68
|
+
for attempt in range(1, max_retries + 1):
|
|
69
|
+
try:
|
|
70
|
+
return fn()
|
|
71
|
+
except Exception as e:
|
|
72
|
+
if attempt == max_retries or not is_retryable(e):
|
|
73
|
+
raise
|
|
74
|
+
sleep = wait * 2 ** (attempt - 1) * (0.5 + random.random())
|
|
75
|
+
log.warning('retryable error (attempt %d/%d), sleeping %.0fs: %s',
|
|
76
|
+
attempt, max_retries, sleep, e)
|
|
77
|
+
time.sleep(sleep)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def init_ee(project: str, base_url: str | None) -> None:
|
|
81
|
+
"""Initialize Earth Engine (used as the worker-pool initializer)."""
|
|
82
|
+
def go():
|
|
83
|
+
if base_url:
|
|
84
|
+
ee.Initialize(project=project, opt_url=base_url)
|
|
85
|
+
else:
|
|
86
|
+
ee.Initialize(project=project)
|
|
87
|
+
with_retries(go)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_info(obj, cfg):
|
|
91
|
+
return with_retries(obj.getInfo, cfg.max_retries, cfg.retry_wait_secs)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def download_csv(fc, selectors: list, cfg) -> pl.DataFrame:
|
|
95
|
+
"""Download a FeatureCollection's properties as CSV into polars.
|
|
96
|
+
|
|
97
|
+
ID_PROP is always read as text. Other columns are left for the caller to
|
|
98
|
+
cast; empty CSV fields become nulls.
|
|
99
|
+
"""
|
|
100
|
+
def go():
|
|
101
|
+
url = fc.getDownloadURL(filetype='CSV', selectors=selectors)
|
|
102
|
+
r = requests.get(url, timeout=cfg.download_timeout_secs)
|
|
103
|
+
r.raise_for_status()
|
|
104
|
+
return r.content
|
|
105
|
+
|
|
106
|
+
content = with_retries(go, cfg.max_retries, cfg.retry_wait_secs)
|
|
107
|
+
if not content.strip():
|
|
108
|
+
return pl.DataFrame(schema={c: pl.Utf8 for c in selectors})
|
|
109
|
+
return pl.read_csv(io.BytesIO(content), schema_overrides={ID_PROP: pl.Utf8},
|
|
110
|
+
infer_schema_length=None)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run_jobs(fn, jobs: list, cfg):
|
|
114
|
+
"""Yield fn(job) for each job, in completion order.
|
|
115
|
+
|
|
116
|
+
n_workers == 1 (or a single job) runs in this process. Otherwise a
|
|
117
|
+
multiprocessing pool is used, each worker initializing Earth Engine once
|
|
118
|
+
-- the same pattern as urma_download_v2.py. Scripts that call this must
|
|
119
|
+
sit under `if __name__ == '__main__':` (required by multiprocessing on
|
|
120
|
+
macOS/Windows).
|
|
121
|
+
"""
|
|
122
|
+
if not jobs:
|
|
123
|
+
return
|
|
124
|
+
if cfg.n_workers == 1 or len(jobs) == 1:
|
|
125
|
+
init_ee(cfg.project, cfg.base_url)
|
|
126
|
+
for j in jobs:
|
|
127
|
+
yield fn(j)
|
|
128
|
+
return
|
|
129
|
+
n = min(cfg.n_workers, len(jobs))
|
|
130
|
+
with multiprocessing.Pool(processes=n, initializer=init_ee,
|
|
131
|
+
initargs=(cfg.project, cfg.base_url)) as pool:
|
|
132
|
+
yield from pool.imap_unordered(fn, jobs)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Progress:
|
|
136
|
+
"""Log progress about every 5% of jobs."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, total: int, what: str):
|
|
139
|
+
self.total, self.what = total, what
|
|
140
|
+
self.done = 0
|
|
141
|
+
self.t0 = time.time()
|
|
142
|
+
self.step = max(1, total // 20)
|
|
143
|
+
|
|
144
|
+
def tick(self):
|
|
145
|
+
self.done += 1
|
|
146
|
+
if self.done % self.step == 0 or self.done == self.total:
|
|
147
|
+
el = time.time() - self.t0
|
|
148
|
+
rate = self.done / el * 3600 if el > 0 else float('nan')
|
|
149
|
+
log.info('%s: %d/%d done, %.1f min, %.0f jobs/hr', self.what,
|
|
150
|
+
self.done, self.total, el / 60, rate)
|