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
run_multi_hazard_analysis
async
¶
run_multi_hazard_analysis(modules: list[str], aoi_geojson: dict, start_date: str, end_date: str, country: str, gee_project: str = '', extra_params: dict[str, dict] | None = None) -> dict[str, AnalysisOutput]
Run two different risk modules against the same area of interest and
date range, for side-by-side comparison of different hazards (as
opposed to run_analysis run twice for the same hazard across two
different regions).
The two modules run sequentially, sharing one DaskEngine — running
them concurrently would double peak memory for no benefit under this
package's typical single-worker deployment.
Parameters¶
modules:
Exactly two distinct module names, e.g. ["food_security", "land_degradation"].
extra_params:
Per-module extra parameters, e.g. {"food_security": {"model_type": "rf"}, ...}.
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
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | |
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 ¶
fetch_population_count(aoi: Geometry, scale: int, year: int = 2020, country: str | None = None) -> xr.Dataset
Population COUNT (people per pixel — an additive quantity, not a density) for population-exposure reporting.
Primary source is Google Earth Engine's WorldPop/GP/100m/pop mirror (see
_fetch_population_count_gee) — fast, no full-file download. Falls back to
worldpop.org's own data catalog (_fetch_population_count_worldpop_org,
which downloads and caches the whole per-country GeoTIFF — much slower,
especially for large countries) whenever the GEE path fails outright, or
"succeeds" with no valid pixels over the AOI — verified live that this
happens for some countries/AOIs (e.g. parts of Uganda) even though the
collection has an image for that country/year. The worldpop.org fallback
is only attempted when country resolves to an ISO3 code (see
_resolve_iso3); if it can't be resolved, the original GEE error/emptiness
is raised as-is.
Deliberately separate from any domain's log-transformed pop_density ML feature (e.g. disease/features.py::fetch_pop_density, which now delegates here) — this returns raw counts suitable for summing within a risk zone, never model input.
Source code in core/population.py
fetch_population_count_safe ¶
fetch_population_count_safe(aoi: Geometry, scale: int, year: int = 2020, country: str | None = None) -> xr.Dataset | None
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).