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.
@@ -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')
@@ -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)