core¶
Orchestration, GEE authentication, caching, and distributed execution shared by every domain module. See Concepts for how these fit together.
Analysis contract & base use case¶
base_use_case ¶
AnalysisConfig
dataclass
¶
AnalysisConfig(module: str, aoi_geojson: dict, start_date: str, end_date: str, country: str, extra_params: dict = dict())
Input contract accepted by every BaseUseCase.execute().
Built by core.runner.run_analysis from the arguments passed to the
top-level run_analysis() function; extra_params carries module- and
model-specific overrides (e.g. model_type, scale, n_pixels).
AnalysisOutput
dataclass
¶
AnalysisOutput(module: str, geojson: dict, raster_path: str | dict[str, str] | None, stats: dict, shap: dict | None, charts: dict, metadata: dict)
Output contract returned by every domain use case.
Fields are intentionally module-agnostic dicts/paths so all five domain
modules (drought, flood, food_security, disease, land_degradation) can
share one result shape; treat stats and charts as module-specific
payloads (see each module's model.py build_*_charts for their shape).
BaseUseCase ¶
Bases: ABC
Abstract base for all five climate risk use-case modules. Subclasses must implement fetch_data, preprocess, and run_model. execute() orchestrates the full pipeline with caching.
Source code in core/base_use_case.py
fetch_data
abstractmethod
¶
preprocess
abstractmethod
¶
Clip to AOI, compute indices, build Dask feature arrays. Must be decorated with @feature_cache.cache in subclasses. Must return in-memory feature dict — no disk writes.
Source code in core/base_use_case.py
run_model
abstractmethod
¶
explain ¶
execute
async
¶
Main entry point. Called by core.runner.run_analysis(). Checks in-process cache first; runs full pipeline on miss. Each sync stage runs in a thread pool so the event loop stays free.
Source code in core/base_use_case.py
Orchestrator¶
runner ¶
register_module ¶
run_analysis
async
¶
run_analysis(module: str, aoi_geojson: dict, start_date: str, end_date: str, country: str, gee_project: str = '', extra_params: dict | None = None, openai_api_key: str | None = None, report_output_dir: str | None = None, map_png_bytes: bytes | None = None) -> AnalysisOutput
Main public API of the climate_change package. The Celery task calls only this function — no other file in the infrastructure layer imports climate_change directly.
Parameters¶
gee_project:
Google Earth Engine Cloud project ID. Falls back to the
GEE_PROJECT environment variable when not supplied.
Authentication runs via drought_monitoring.gee.authenticate
before any use-case logic executes.
Optional
openai_api_key: if provided, GPT-4o interprets the results and the text is stored in output.metadata["ai_interpretation"]. report_output_dir: if provided, a PDF report is written to this directory and the path stored in output.metadata["report_path"]. map_png_bytes: optional PNG screenshot embedded in the PDF report.
Source code in core/runner.py
Google Earth Engine authentication¶
gee_auth ¶
GEE authentication helpers for climate_change.
Usage contexts¶
Local / notebook
Leave gee_project empty — the project is resolved from the .env
file, the GEE_PROJECT environment variable, or an interactive prompt.
Backend / API (recommended pattern)
1. At user registration, call validate_gee_project(project_id)
to verify the project is reachable before storing it in the database.
2. On every analysis request, pass the stored project ID through
run_analysis(..., gee_project=user.gee_project).
3. Users are never prompted again — authentication is automatic from
their stored project ID.
Example FastAPI handler::
@router.post("/analyse")
async def analyse(body: AnalysisRequest, user: User = Depends(get_current_user)):
# gee_project was verified at registration and stored in the DB.
# Authentication happens automatically — the user is never asked again.
return await run_analysis(
module=body.module,
aoi_geojson=body.aoi,
start_date=body.start_date,
end_date=body.end_date,
country=body.country,
gee_project=user.gee_project,
)
Database security guidance¶
The GEE project ID stored in the database is not a secret key — it is a Google Cloud project identifier. However, it should be protected because:
- If leaked it could be used by someone with valid GEE credentials to bill charges against the project owner's quota.
- Users must never be able to read or impersonate another user's project ID.
Recommended storage practices:
-
Encrypt the column at rest — use your database's column-level encryption (PostgreSQL
pgcrypto, MySQLAES_ENCRYPT) or a secrets manager (AWS KMS, GCP Cloud KMS, HashiCorp Vault) to encrypt the value before writing it. The encryption key lives outside the database. -
Row-level security — ensure the API never returns another user's
gee_projectfield. Add a database-level policy (e.g. PostgreSQL RLS) so queries are automatically scoped to the authenticated user. -
Encrypt the full database at rest — all major cloud databases (RDS, Cloud SQL, Supabase) support transparent disk-level encryption; enable it.
-
Audit access — log every read of the
gee_projectcolumn so suspicious bulk-reads are detectable.
What is NOT stored here:
- OAuth tokens and service-account keys stay on the server filesystem after
earthengine authenticate. They are never stored in the user DB. Only the project ID (a non-secret identifier) is stored.
ensure_gee ¶
Authenticate and initialise GEE for the given project, once per project per process.
Multi-user API behaviour¶
Each unique project ID is initialised independently. Requests from User A and User B (different GEE projects) each trigger their own initialisation without interfering with each other. Subsequent requests from the same user skip re-initialisation (idempotent).
Project resolution order¶
projectargument (always used in API context)GEE_PROJECTenvironment variable /.envfile (local use)- Interactive prompt (when
allow_prompt=True, local / notebook only)
Parameters¶
project : str
GEE Cloud project ID. In API context this is always the value stored
for the authenticated user — never empty.
allow_prompt : bool
False in API/server contexts; raises immediately on missing project.
Source code in core/gee_auth.py
validate_gee_project ¶
Verify that a GEE project ID is valid and reachable.
Call this once at user registration before storing the project ID in
the database. If this succeeds, ensure_gee will authenticate
automatically on every subsequent request — the user is never asked again.
Parameters¶
project : str The GEE Cloud project ID the user supplied during registration.
Raises¶
ValueError The project ID string is empty. RuntimeError GEE authentication or initialisation failed for this project.
Example (FastAPI registration endpoint)::
@router.post("/register")
async def register(body: RegisterRequest):
try:
validate_gee_project(body.gee_project)
except (ValueError, RuntimeError) as exc:
raise HTTPException(status_code=422, detail=str(exc))
# Store encrypted in DB — user will never be prompted again.
user = await create_user(body, gee_project=encrypt(body.gee_project))
return {"id": user.id}
Source code in core/gee_auth.py
startup_init_gee ¶
Authenticate and initialise GEE once at server startup using the
organisation's project configured in GEE_PROJECT (env / .env file).
Call this from the FastAPI lifespan inside asyncio.to_thread because
ee.Initialize is synchronous.
Logs a warning rather than raising if GEE_PROJECT is not set, so the
server still starts in environments where GEE is not yet configured.
Source code in core/gee_auth.py
Caching¶
cache ¶
Distributed execution¶
dask_engine ¶
DaskEngine ¶
Manages a Dask LocalCluster for distributed in-memory computation. Singleton pattern — one cluster per process.
Two tiers of parallelism: - Dask Futures (submit / gather) : CPU-bound tasks (ML training, xarray ops). - I/O thread pool (run_io_parallel): GEE HTTP downloads that share a session.
get_client
classmethod
¶
Return the singleton Dask Client, starting a LocalCluster if needed.
Source code in core/dask_engine.py
get_client_if_running
classmethod
¶
Return the active Dask Client, or None if the cluster has not been started.
shutdown
classmethod
¶
submit
classmethod
¶
Submit a callable to the Dask cluster. Returns a Future.
gather
classmethod
¶
Block until all futures complete and return results in submission order.
run_io_parallel
staticmethod
¶
run_io_parallel(tasks: dict[str, Callable[[], Any]], max_workers: int | None = None) -> dict[str, Any]
Execute zero-arg callables concurrently via threads.
Ideal for GEE HTTP downloads because threads share the authenticated Earth Engine session within a process, avoiding per-process re-auth.
Parameters¶
tasks : {key: zero-arg callable} max_workers: defaults to len(tasks).
Returns¶
dict with the same keys mapping to each callable's return value.
Source code in core/dask_engine.py
clip_raster_to_aoi
staticmethod
¶
Clip a dask-backed xarray DataArray to an AOI polygon — lazy.
Source code in core/dask_engine.py
compute_with_progress
staticmethod
¶
Land-cover masking¶
landcover_mask ¶
worldcover_map ¶
Raw ESA WorldCover v200 'Map' band (class codes 10-100), clipped to aoi.
Source code in core/landcover_mask.py
exclusion_mask ¶
Boolean ee.Image (1 = keep, 0 = excluded) derived from ESA WorldCover landcover classes. Used to mask specific landcover classes (e.g. water bodies, built-up areas) out of an AOI before feature datasets are built or sampled.
Source code in core/landcover_mask.py
fetch_landcover_class_array ¶
Download raw ESA WorldCover class codes over aoi_geojson as an xr.DataArray with (lon, lat) dims. Used where the AOI is only available as GeoJSON (no live ee.Geometry), e.g. to mask an already-computed spatial dataset.
Source code in core/landcover_mask.py
Population exposure¶
population ¶
fetch_population_count ¶
WorldPop GP 100 m population COUNT (people per pixel — an additive quantity, not a density) for population-exposure reporting.
Deliberately separate from any domain's log-transformed pop_density ML feature (e.g. disease/features.py::fetch_pop_density) — this returns raw counts suitable for summing within a risk zone, never model input.
Always fetches at (up to) the native ~100 m resolution regardless of the
requested scale — i.e. min(scale, 100). An earlier version of this
function used ee.Image.reduceResolution(Reducer.sum()) to downsample to a
domain's coarser working scale before download, on the assumption that
would sum native pixels into each output pixel. Verified against live GEE
data this was wrong: reduceResolution's default area-weighting turns
sum() into something closer to a coverage fraction (single-pixel test:
output value was ~1 instead of the true ~25/~100 contributing pixels),
and even with .unweighted() the boundary handling still over-counted by
20-60%+ against a server-side reduceRegion ground truth, including at a
realistic ~100km county-sized AOI (not just a small edge-effect-prone
test box). There is no reliable way found to make GEE aggregate this
correctly server-side, so this function never downsamples population at
all — see population_exposure() below for how the resolution mismatch
with a domain's coarser risk-classification grid is actually handled (by
upsampling the classification, not downsampling the population).
Returns Dataset with variable 'population', values in people-per-pixel, already masked (0.0) where WorldPop's -99999 nodata sentinel appears — verified live that the GeoTIFF export does not set a GDAL/rasterio nodata tag (da.rio.nodata comes back None), so summing the raw array would otherwise corrupt totals by many orders of magnitude.
Source code in core/population.py
fetch_population_count_safe ¶
Best-effort wrapper around fetch_population_count.
Population exposure is an enrichment on top of the core risk analysis, not a required input — WorldPop being unavailable for a given AOI/year (or a transient network failure) must not crash the whole analysis the way a missing rainfall/temperature/NDVI band legitimately should. Returns None on any failure; callers should skip exposure reporting in that case.
Source code in core/population.py
population_by_class ¶
population_by_class(class_grid: ndarray, population_grid: ndarray, classes: list) -> dict[str, float]
Zonal sum of population per value present in class_grid.
class_grid and population_grid must already be the same shape and cover
the same grid. In practice, prefer population_exposure() below, which
handles the (typical) case where the risk-classification grid is coarser
than population's native resolution — calling this directly requires the
caller to have already resolved that mismatch. Works with int-coded risk
grids (disease/flood/food_security/land_degradation) or string-labelled
class grids (drought's CDI severity classes) alike — classes is
whatever set of class tokens is meaningful for that grid's dtype.
Returned dict is keyed by str(class token).
Source code in core/population.py
upsample_class_grid ¶
upsample_class_grid(class_grid: ndarray, class_transform, target_shape: tuple[int, int], target_transform, crs: str = 'EPSG:4326') -> np.ndarray
Nearest-neighbour upsample a (coarse) classified grid onto a finer target grid — typically population's native ~100 m resolution. Nearest-neighbour is the semantically correct choice for a categorical class grid (each finer output pixel just inherits its parent coarse cell's class), unlike downsampling an additive quantity like population counts, which is why the resolution mismatch is resolved this direction and not the other.
Source code in core/population.py
population_exposure ¶
population_exposure(class_grid: ndarray, class_transform, population_ds: Dataset, classes: list) -> dict[str, float]
Zonal sum of population per class, handling the resolution mismatch between a domain's (coarser) risk-classification grid and population's native ~100 m grid: upsamples class_grid onto population's grid via nearest-neighbour, then delegates to population_by_class. This is the primary entry point domains should use — see population_by_class's docstring for why population itself is never downsampled instead.
class_grid may hold int-coded classes (disease/flood/food_security/
land_degradation's risk grids) or string labels (drought's CDI severity
classes) — GDAL/rasterio's reprojection (used internally to upsample
class_grid) only supports numeric dtypes, so a non-numeric class_grid is
transparently encoded to small integers before upsampling and decoded
back to the original classes labels in the returned dict.
Source code in core/population.py
at_risk_population ¶
Sum population across the given at-risk class labels (keys of pop_by_class).