Skip to content

land_degradation

Vegetation decline and rangeland degradation detection. Entry point: LandDegradationUseCase.

from climate_change import LandDegradationUseCase

Use case

use_case

LandDegradationUseCase

LandDegradationUseCase(dask_engine: DaskEngine)

Bases: BaseUseCase

Entry point for the land degradation domain.

Minimal config (all optional — defaults match Northern Burkina Faso 2015–2024):

{ "aoi_geojson": {"type": "Polygon", "coordinates": [...]}, "gee_project": "your-gee-project-id", "start_date": "2015-01-01", "end_date": "2024-12-31", "model_type": "lgbm", # "rf" | "lgbm" | "ensemble" "n_pixels": 3000, "scale": 1000, # metres — uniform for all feature rasters "output_dir": "outputs", "prefix": "land_degradation", }

Source code in land_degradation/use_case.py
def __init__(self, dask_engine: DaskEngine) -> None:
    super().__init__(dask_engine)

fetch_data

fetch_data(config: AnalysisConfig) -> dict

Authenticate GEE and parse the AOI geometry (see _fetch_from_dict).

Source code in land_degradation/use_case.py
def fetch_data(self, config: AnalysisConfig) -> dict:
    """Authenticate GEE and parse the AOI geometry (see `_fetch_from_dict`)."""
    flat: dict = {
        "aoi_geojson": config.aoi_geojson,
        "start_date": config.start_date,
        "end_date": config.end_date,
        "country": config.country,
        **config.extra_params,
    }
    return self._fetch_from_dict(flat)

preprocess

preprocess(raw_data: dict, config: AnalysisConfig) -> dict

Build the feature stack and sample training pixels (see _preprocess_raw).

Source code in land_degradation/use_case.py
def preprocess(self, raw_data: dict, config: AnalysisConfig) -> dict:
    """Build the feature stack and sample training pixels (see `_preprocess_raw`)."""
    return self._preprocess_raw(raw_data)

run_model

run_model(features: dict, config: AnalysisConfig) -> AnalysisOutput

Train models, export the degradation COG, and package an AnalysisOutput.

Source code in land_degradation/use_case.py
def run_model(self, features: dict, config: AnalysisConfig) -> AnalysisOutput:
    """Train models, export the degradation COG, and package an `AnalysisOutput`."""
    model_type = config.extra_params.get("model_type", "lgbm")
    dict_config = {"model_type": model_type, **config.extra_params}
    result = self._run_model_dict(features, dict_config)

    raster_paths: dict[str, str] | None = None
    raster_error: str | None = None
    try:
        raster_result = export_degradation_cog(
            rf_model=features["_rf"],
            lgbm_model=features["_lgbm"],
            scaler=features["_scaler"],
            datasets=features["datasets"],
            output_dir=dict_config.get("output_dir", "outputs"),
            prefix=dict_config.get("prefix", "land_degradation"),
            model_type=model_type,
            aoi_geojson=config.aoi_geojson,
            scale=int(dict_config.get("scale", 1000)),
        )
        raster_paths = {"degradation_risk": raster_result["degradation_risk"]}
        _apply_raster_risk_pct(
            result,
            raster_result["risk_pct"],
            raster_result["risk_ha"],
            raster_result.get("population_by_class"),
            raster_result.get("total_population"),
        )
    except Exception as exc:
        raster_error = str(exc)

    geojson_features = []
    if config.aoi_geojson:
        geojson_features.append(
            {
                "type": "Feature",
                "geometry": config.aoi_geojson,
                "properties": {"type": "boundary"},
            }
        )
    for p in result.pop("_sample_points", []):
        geojson_features.append(
            {
                "type": "Feature",
                "geometry": {"type": "Point", "coordinates": [p["lon"], p["lat"]]},
                "properties": {"risk_class": p["risk_class"]},
            }
        )

    metadata = {
        "model": model_type,
        "country": config.country,
        "start_date": config.start_date,
        "end_date": config.end_date,
        "raster": raster_paths or {},
    }
    if raster_error:
        metadata["raster_error"] = raster_error

    return AnalysisOutput(
        module="land_degradation",
        geojson={"type": "FeatureCollection", "features": geojson_features},
        raster_path=raster_paths,
        stats={**result["stats"], "country": config.country},
        shap=result.get("charts", {}).get("shap"),
        charts=result.get("charts", {}),
        metadata=metadata,
    )

run

run(config: dict) -> dict

Single AOI analysis — full pipeline in one call.

Source code in land_degradation/use_case.py
def run(self, config: dict) -> dict:
    """Single AOI analysis — full pipeline in one call."""
    raw_data = self._fetch_from_dict(config)
    features = self._preprocess_raw(raw_data)
    result = self._run_model_dict(features, config)

    cog_paths: dict[str, str] | None = None
    cog_error: str | None = None
    try:
        raster_result = export_degradation_cog(
            rf_model=features["_rf"],
            lgbm_model=features["_lgbm"],
            scaler=features["_scaler"],
            datasets=features["datasets"],
            output_dir=config.get("output_dir", "outputs"),
            prefix=config.get("prefix", "land_degradation"),
            model_type=config.get("model_type", "lgbm"),
            aoi_geojson=config.get("aoi_geojson"),
            scale=int(config.get("scale", 1000)),
        )
        cog_paths = {"degradation_risk": raster_result["degradation_risk"]}
        _apply_raster_risk_pct(
            result,
            raster_result["risk_pct"],
            raster_result["risk_ha"],
            raster_result.get("population_by_class"),
            raster_result.get("total_population"),
        )
    except Exception as exc:
        _log.warning("Land degradation COG export failed: %s", exc, exc_info=True)
        cog_error = str(exc)
    result["raster"] = cog_paths or {}
    if cog_error:
        result["raster_error"] = cog_error
    result["stats"].update(
        {
            "bbox": features["bbox"],
            "prefix": config.get("prefix", "land_degradation"),
        }
    )
    return result

run_date_ranges

run_date_ranges(config: dict, date_ranges: list[dict]) -> list[dict]

Run the same AOI over multiple date-range configurations in parallel.

Source code in land_degradation/use_case.py
def run_date_ranges(self, config: dict, date_ranges: list[dict]) -> list[dict]:
    """Run the same AOI over multiple date-range configurations in parallel."""
    merged = [{**config, **dr} for dr in date_ranges]
    return self._run_parallel(merged)

run_multi_regions

run_multi_regions(configs: list[dict]) -> list[dict]

Run multiple AOI configs (same timeframe, different regions) in parallel.

Source code in land_degradation/use_case.py
def run_multi_regions(self, configs: list[dict]) -> list[dict]:
    """Run multiple AOI configs (same timeframe, different regions) in parallel."""
    return self._run_parallel(configs)

Features

features

fetch_ndvi_stack

fetch_ndvi_stack(aoi: Geometry, start: str, end: str, scale: int = 1000) -> xr.Dataset

Download MODIS MOD13A3 pixel-wise NDVI trend at scale metres. Returns Dataset with variables ndvi_slope, ndvi_mean, ndvi_cv.

Source code in land_degradation/features.py
def fetch_ndvi_stack(
    aoi: ee.Geometry,
    start: str,
    end: str,
    scale: int = 1000,
) -> xr.Dataset:
    """
    Download MODIS MOD13A3 pixel-wise NDVI trend at `scale` metres.
    Returns Dataset with variables ndvi_slope, ndvi_mean, ndvi_cv.
    """

    def _scale_ndvi(img: ee.Image) -> ee.Image:
        return img.multiply(0.0001).copyProperties(img, ["system:time_start"])

    def _add_time(img: ee.Image) -> ee.Image:
        t = img.date().difference(ee.Date(start), "year")
        return img.addBands(ee.Image(t).rename("time").float())

    modis = (
        ee.ImageCollection("MODIS/061/MOD13A3")
        .filterBounds(aoi)
        .filterDate(start, end)
        .select("NDVI")
        .map(_scale_ndvi)
    )
    trend = modis.map(_add_time).select(["time", "NDVI"]).reduce(ee.Reducer.linearFit())
    ndvi_slope = trend.select("scale").rename("ndvi_slope")
    ndvi_mean = modis.mean().rename("ndvi_mean")
    ndvi_std = modis.reduce(ee.Reducer.stdDev())
    ndvi_cv = ndvi_std.divide(ndvi_mean.abs().add(1e-6)).rename("ndvi_cv")

    stack = ndvi_slope.addBands(ndvi_mean).addBands(ndvi_cv)
    url = stack.getDownloadURL(
        {"region": aoi, "scale": scale, "crs": "EPSG:4326", "format": "GEO_TIFF"}
    )
    raw = _download_band(url)
    raw = raw.assign_coords(band=["ndvi_slope", "ndvi_mean", "ndvi_cv"])
    return raw.to_dataset(dim="band").rename({"x": "lon", "y": "lat"})

fetch_ndvi_timeseries

fetch_ndvi_timeseries(aoi: Geometry, start: str, end: str) -> pd.Series

Fetch area-averaged monthly MODIS NDVI and return as an annual pandas Series. Index = integer years; values = annual mean NDVI.

Source code in land_degradation/features.py
def fetch_ndvi_timeseries(
    aoi: ee.Geometry,
    start: str,
    end: str,
) -> pd.Series:
    """
    Fetch area-averaged monthly MODIS NDVI and return as an annual pandas Series.
    Index = integer years; values = annual mean NDVI.
    """

    def _scale_ndvi(img: ee.Image) -> ee.Image:
        return img.multiply(0.0001).copyProperties(img, ["system:time_start"])

    def _mean_feat(img: ee.Image) -> ee.Feature:
        v = img.reduceRegion(ee.Reducer.mean(), aoi, 500, maxPixels=int(1e9))
        return cast("ee.Feature", ee.Feature(None, v).set("date", img.date().format("YYYY-MM")))

    modis = (
        ee.ImageCollection("MODIS/061/MOD13A3")
        .filterBounds(aoi)
        .filterDate(start, end)
        .select("NDVI")
        .map(_scale_ndvi)
    )
    records = cast(dict, ee.FeatureCollection(modis.map(_mean_feat)).getInfo())["features"]
    ts_df = pd.DataFrame([f["properties"] for f in records]).sort_values("date").dropna()
    ndvi_monthly = pd.Series(
        ts_df["NDVI"].values,
        index=pd.to_datetime(ts_df["date"]),
        name="NDVI",
    )
    ndvi_annual = ndvi_monthly.resample("YE").mean()
    ndvi_annual.index = pd.DatetimeIndex(ndvi_annual.index).year.astype(int)
    return ndvi_annual

fetch_s2_indices

fetch_s2_indices(aoi: Geometry, start: str, end: str, scale: int = 5000) -> xr.Dataset

Compute annual Sentinel-2 BSI and NDTI composites (cloud < 30 %). Downloads all years as a single multi-band GeoTIFF and returns the temporal mean as a 2-variable Dataset with bsi and ndti.

Source code in land_degradation/features.py
def fetch_s2_indices(
    aoi: ee.Geometry,
    start: str,
    end: str,
    scale: int = 5000,
) -> xr.Dataset:
    """
    Compute annual Sentinel-2 BSI and NDTI composites (cloud < 30 %).
    Downloads all years as a single multi-band GeoTIFF and returns
    the temporal mean as a 2-variable Dataset with bsi and ndti.
    """
    years = list(range(int(start[:4]), int(end[:4]) + 1))

    def _annual(year: int) -> tuple[ee.Image, ee.Image]:
        col = (
            ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED")
            .filterBounds(aoi)
            .filterDate(f"{year}-01-01", f"{year}-12-31")
            .filter(ee.Filter.lt("CLOUDY_PIXEL_PERCENTAGE", 30))
            .select(["B2", "B4", "B8", "B11", "B12"])
            .map(lambda i: i.multiply(0.0001))
            .median()
            .clip(aoi)
        )
        bsi = col.expression(
            "(SWIR1 + RED - NIR - BLUE) / (SWIR1 + RED + NIR + BLUE)",
            {
                "SWIR1": col.select("B11"),
                "RED": col.select("B4"),
                "NIR": col.select("B8"),
                "BLUE": col.select("B2"),
            },
        ).rename(f"bsi_{year}")
        ndti = col.expression(
            "(SWIR1 - SWIR2) / (SWIR1 + SWIR2)",
            {"SWIR1": col.select("B11"), "SWIR2": col.select("B12")},
        ).rename(f"ndti_{year}")
        return bsi, ndti

    bsi_list, ndti_list = [], []
    for yr in years:
        b, n = _annual(yr)
        bsi_list.append(b)
        ndti_list.append(n)

    band_names = [f"bsi_{yr}" for yr in years] + [f"ndti_{yr}" for yr in years]
    combined = ee.Image.cat(bsi_list + ndti_list)
    url = combined.getDownloadURL(
        {"region": aoi, "scale": scale, "crs": "EPSG:4326", "format": "GEO_TIFF"}
    )
    raw = _download_band(url)
    raw = raw.assign_coords(band=band_names)
    ds = raw.to_dataset(dim="band").rename({"x": "lon", "y": "lat"})

    bsi_mean = xr.concat([ds[f"bsi_{yr}"] for yr in years], dim="year").mean(dim="year")
    ndti_mean = xr.concat([ds[f"ndti_{yr}"] for yr in years], dim="year").mean(dim="year")
    return xr.Dataset({"bsi": bsi_mean, "ndti": ndti_mean})

fetch_terrain_slope

fetch_terrain_slope(aoi: Geometry, scale: int = 1000) -> xr.Dataset

Download SRTM-derived slope at scale metres.

Source code in land_degradation/features.py
def fetch_terrain_slope(aoi: ee.Geometry, scale: int = 1000) -> xr.Dataset:
    """Download SRTM-derived slope at `scale` metres."""
    slope = ee.Terrain.slope(ee.Image("USGS/SRTMGL1_003")).clip(aoi).rename("slope_terrain")
    url = slope.getDownloadURL(
        {"region": aoi, "scale": scale, "crs": "EPSG:4326", "format": "GEO_TIFF"}
    )
    da = _download_band(url).squeeze()
    return xr.Dataset({"slope_terrain": da.rename({"x": "lon", "y": "lat"})})

fetch_rainfall_anomaly

fetch_rainfall_anomaly(aoi: Geometry, start: str, end: str, scale: int = 1000) -> xr.Dataset

Pixel-wise CHIRPS precipitation linear trend (mm yr⁻¹) over [start, end]. Returns Dataset with variable rainfall_anom.

Source code in land_degradation/features.py
def fetch_rainfall_anomaly(
    aoi: ee.Geometry,
    start: str,
    end: str,
    scale: int = 1000,
) -> xr.Dataset:
    """
    Pixel-wise CHIRPS precipitation linear trend (mm yr⁻¹) over [start, end].
    Returns Dataset with variable rainfall_anom.
    """

    def _add_t(img: ee.Image) -> ee.Image:
        t = img.date().difference(ee.Date(start), "year")
        return img.addBands(ee.Image(t).rename("time").float())

    chirps = (
        ee.ImageCollection("UCSB-CHG/CHIRPS/DAILY")
        .filterBounds(aoi)
        .filterDate(start, end)
        .select("precipitation")
    )
    rain_anom = (
        chirps.map(_add_t)
        .select(["time", "precipitation"])
        .reduce(ee.Reducer.linearFit())
        .select("scale")
        .rename("rainfall_anom")
        .clip(aoi)
    )
    url = rain_anom.getDownloadURL(
        {"region": aoi, "scale": scale, "crs": "EPSG:4326", "format": "GEO_TIFF"}
    )
    da = _download_band(url).squeeze()
    return xr.Dataset({"rainfall_anom": da.rename({"x": "lon", "y": "lat"})})

fetch_landcover

fetch_landcover(aoi: Geometry, scale: int = 100) -> xr.Dataset

Download ESA WorldCover 2021. Returns Dataset with variable Map.

Source code in land_degradation/features.py
def fetch_landcover(aoi: ee.Geometry, scale: int = 100) -> xr.Dataset:
    """Download ESA WorldCover 2021. Returns Dataset with variable Map."""
    worldcover = ee.ImageCollection("ESA/WorldCover/v200").first().select("Map").clip(aoi)
    url = worldcover.getDownloadURL(
        {"region": aoi, "scale": scale, "crs": "EPSG:4326", "format": "GEO_TIFF"}
    )
    da = _download_band(url).squeeze()
    return xr.Dataset({"Map": da.rename({"x": "lon", "y": "lat"})})

build_feature_datasets

build_feature_datasets(aoi: Geometry, config: dict) -> dict[str, xr.Dataset]

Download all feature bands from GEE in parallel. The five fetch calls are independent HTTP requests; they run concurrently via DaskEngine.run_io_parallel (ThreadPoolExecutor) sharing the GEE session. Keyed by band group; consumed by both sample_training_data and cog_export.

Source code in land_degradation/features.py
def build_feature_datasets(aoi: ee.Geometry, config: dict) -> dict[str, xr.Dataset]:
    """
    Download all feature bands from GEE in parallel.
    The five fetch calls are independent HTTP requests; they run concurrently
    via DaskEngine.run_io_parallel (ThreadPoolExecutor) sharing the GEE session.
    Keyed by band group; consumed by both sample_training_data and cog_export.
    """
    from climate_change.core.dask_engine import DaskEngine
    from climate_change.core.population import fetch_population_count_safe

    scale = config.get("scale", 1000)
    start = config.get("start_date", "2015-01-01")
    end = config.get("end_date", "2024-12-31")

    return DaskEngine.run_io_parallel(
        {
            "ndvi": lambda: fetch_ndvi_stack(aoi, start, end, scale=scale),
            "s2": lambda: fetch_s2_indices(aoi, start, end),  # fixed at 5000 m
            "terrain": lambda: fetch_terrain_slope(aoi, scale=scale),
            "rainfall": lambda: fetch_rainfall_anomaly(aoi, start, end, scale=scale),
            "landcover": lambda: fetch_landcover(aoi, scale=100),
            # Raw population COUNT for exposure reporting (population
            # dependent on/living in degraded land). Best-effort — see
            # core.population.fetch_population_count_safe.
            "population_count": lambda: fetch_population_count_safe(aoi, scale=scale),
        }
    )

build_gee_feature_stack

build_gee_feature_stack(aoi: Geometry, config: dict) -> ee.Image

Assemble the 8-band GEE image used for stratified pixel sampling. Uses the same band order as FEATURE_COLS.

Source code in land_degradation/features.py
def build_gee_feature_stack(aoi: ee.Geometry, config: dict) -> ee.Image:
    """
    Assemble the 8-band GEE image used for stratified pixel sampling.
    Uses the same band order as FEATURE_COLS.
    """
    start = config.get("start_date", "2015-01-01")
    end = config.get("end_date", "2024-12-31")
    scale = config.get("scale", 1000)
    years = list(range(int(start[:4]), int(end[:4]) + 1))

    # MODIS NDVI trend bands
    def _scale_ndvi(img: ee.Image) -> ee.Image:
        return img.multiply(0.0001).copyProperties(img, ["system:time_start"])

    def _add_time(img: ee.Image) -> ee.Image:
        t = img.date().difference(ee.Date(start), "year")
        return img.addBands(ee.Image(t).rename("time").float())

    modis = (
        ee.ImageCollection("MODIS/061/MOD13A3")
        .filterBounds(aoi)
        .filterDate(start, end)
        .select("NDVI")
        .map(_scale_ndvi)
    )
    trend = modis.map(_add_time).select(["time", "NDVI"]).reduce(ee.Reducer.linearFit())
    ndvi_slope = trend.select("scale").rename("ndvi_slope")
    ndvi_mean = modis.mean().rename("ndvi_mean")
    ndvi_std = modis.reduce(ee.Reducer.stdDev())
    ndvi_cv = ndvi_std.divide(ndvi_mean.abs().add(1e-6)).rename("ndvi_cv")

    # S2 annual BSI + NDTI → temporal mean
    def _annual_gee(year: int) -> tuple[ee.Image, ee.Image]:
        col = (
            ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED")
            .filterBounds(aoi)
            .filterDate(f"{year}-01-01", f"{year}-12-31")
            .filter(ee.Filter.lt("CLOUDY_PIXEL_PERCENTAGE", 30))
            .select(["B2", "B4", "B8", "B11", "B12"])
            .map(lambda i: i.multiply(0.0001))
            .median()
            .clip(aoi)
        )
        bsi = col.expression(
            "(SWIR1 + RED - NIR - BLUE) / (SWIR1 + RED + NIR + BLUE)",
            {
                "SWIR1": col.select("B11"),
                "RED": col.select("B4"),
                "NIR": col.select("B8"),
                "BLUE": col.select("B2"),
            },
        )
        ndti = col.expression(
            "(SWIR1 - SWIR2) / (SWIR1 + SWIR2)",
            {"SWIR1": col.select("B11"), "SWIR2": col.select("B12")},
        )
        return bsi, ndti

    bsi_list, ndti_list = [], []
    for yr in years:
        b, n = _annual_gee(yr)
        bsi_list.append(b)
        ndti_list.append(n)

    bsi_img = ee.Image.cat(bsi_list).reduce(ee.Reducer.mean()).rename("bsi")
    ndti_img = ee.Image.cat(ndti_list).reduce(ee.Reducer.mean()).rename("ndti")

    # Terrain slope
    slope = (
        ee.Terrain.slope(ee.Image("USGS/SRTMGL1_003"))
        .clip(aoi)
        .reproject("EPSG:4326", None, scale)
        .rename("slope_terrain")
    )

    # CHIRPS rainfall anomaly
    def _add_t(img: ee.Image) -> ee.Image:
        t = img.date().difference(ee.Date(start), "year")
        return img.addBands(ee.Image(t).rename("time").float())

    rain_anom = (
        ee.ImageCollection("UCSB-CHG/CHIRPS/DAILY")
        .filterBounds(aoi)
        .filterDate(start, end)
        .select("precipitation")
        .map(_add_t)
        .select(["time", "precipitation"])
        .reduce(ee.Reducer.linearFit())
        .select("scale")
        .rename("rainfall_anom")
        .clip(aoi)
        .reproject("EPSG:4326", None, scale)
    )

    # ESA WorldCover (normalised)
    lc_norm = (
        ee.ImageCollection("ESA/WorldCover/v200")
        .first()
        .select("Map")
        .divide(10)
        .subtract(1)
        .rename("land_cover")
        .clip(aoi)
        .reproject("EPSG:4326", None, scale)
    )

    stack = ee.Image.cat(
        [
            ndvi_slope,
            ndvi_mean,
            ndvi_cv,
            bsi_img.reproject("EPSG:4326", None, scale),
            ndti_img.reproject("EPSG:4326", None, scale),
            slope,
            rain_anom,
            lc_norm,
        ]
    )
    # Exclude water bodies from feature preparation/sampling — land degradation
    # is not a meaningful concept over permanent water.
    valid_land = exclusion_mask(aoi, [WATER_CLASS])
    return stack.updateMask(valid_land)

sample_training_data

sample_training_data(feature_stack: Image, aoi: Geometry, n_pixels: int = 3000, scale: int = 1000, seed: int = 42) -> pd.DataFrame

Sample n_pixels from the GEE feature stack and assign binary degradation labels. Labels: composite score >= DEGRADED_SCORE_THRESHOLD → 1 (Degraded), remainder → 0 (Not Degraded). Returns DataFrame with FEATURE_COLS + ['deg_score', 'deg_class'].

Source code in land_degradation/features.py
def sample_training_data(
    feature_stack: ee.Image,
    aoi: ee.Geometry,
    n_pixels: int = 3000,
    scale: int = 1000,
    seed: int = 42,
) -> pd.DataFrame:
    """
    Sample n_pixels from the GEE feature stack and assign binary degradation labels.
    Labels: composite score >= DEGRADED_SCORE_THRESHOLD → 1 (Degraded),
            remainder → 0 (Not Degraded).
    Returns DataFrame with FEATURE_COLS + ['deg_score', 'deg_class'].
    """
    sample = feature_stack.sample(
        region=aoi,
        scale=scale,
        numPixels=n_pixels,
        seed=seed,
        dropNulls=True,
        geometries=False,
    )
    records = (sample.getInfo() or {}).get("features", [])
    df = pd.DataFrame([f["properties"] for f in records]).dropna()
    df = df[FEATURE_COLS].copy()

    scores = _compute_deg_score(df)
    df["deg_score"] = scores
    df["deg_class"] = (scores >= DEGRADED_SCORE_THRESHOLD).astype(int)
    return df

align_datasets

align_datasets(datasets: dict[str, Dataset], ref_key: str = 'ndvi', method_continuous: InterpOptions = 'linear', method_categorical: InterpOptions = 'nearest') -> dict[str, xr.Dataset]

Interpolate all datasets onto the NDVI reference grid. Land cover is treated as categorical (nearest-neighbour). population_count is passed through entirely unchanged, at its own native ~100 m resolution — it is never interpolated onto this (coarser) reference grid. Summing population within a risk zone requires the opposite: upsampling the (categorical) classification onto population's native grid — see core.population.population_exposure, which cog_export.py uses for this instead of relying on align_datasets. Interpolating population here would silently corrupt totals (verified against live GEE data — see core.population.fetch_population_count's docstring).

Each dataset is chunked before interpolation so that xarray produces Dask-backed lazy arrays; dask.compute() then materialises them all in a single parallel scheduler pass.

Source code in land_degradation/features.py
def align_datasets(
    datasets: dict[str, xr.Dataset],
    ref_key: str = "ndvi",
    method_continuous: InterpOptions = "linear",
    method_categorical: InterpOptions = "nearest",
) -> dict[str, xr.Dataset]:
    """
    Interpolate all datasets onto the NDVI reference grid. Land cover is
    treated as categorical (nearest-neighbour). population_count is passed
    through entirely unchanged, at its own native ~100 m resolution — it is
    never interpolated onto this (coarser) reference grid. Summing
    population within a risk zone requires the opposite: upsampling the
    (categorical) classification onto population's native grid — see
    core.population.population_exposure, which cog_export.py uses for this
    instead of relying on align_datasets. Interpolating population here
    would silently corrupt totals (verified against live GEE data — see
    core.population.fetch_population_count's docstring).

    Each dataset is chunked before interpolation so that xarray produces
    Dask-backed lazy arrays; dask.compute() then materialises them all
    in a single parallel scheduler pass.
    """
    from dask.base import compute

    _CHUNK = {"lat": 256, "lon": 256}
    ref = datasets[ref_key]

    lazy: dict[str, xr.Dataset] = {ref_key: ref}
    for key, ds in datasets.items():
        if key == ref_key or ds is None or key == "population_count":
            continue
        method = method_categorical if key == "landcover" else method_continuous
        lazy[key] = ds.chunk(_CHUNK).interp(lat=ref.lat, lon=ref.lon, method=method)

    keys = list(lazy)
    computed = compute(*[lazy[k] for k in keys])
    result = dict(zip(keys, computed))
    pop_ds = datasets.get("population_count")
    if pop_ds is not None:
        result["population_count"] = pop_ds
    return result

Model

model

FeatureNamedLGBMClassifier

Bases: LGBMClassifier

LightGBM classifier that preserves feature names for array inputs.

LandDegradationModel

LandDegradationModel()

Orchestrates the full ML pipeline for a single land degradation analysis. Always trains RF + LightGBM. config['model_type'] selects which predictions drive the primary risk distribution: "rf" — Random Forest "lgbm" — LightGBM (default) "ensemble" — majority vote of RF + LightGBM Trained models and scaler are stored on self for use by cog_export.

Source code in land_degradation/model.py
def __init__(self) -> None:
    self.rf: RandomForestClassifier | None = None
    self.lgbm: lgb.LGBMClassifier | None = None
    self.scaler: StandardScaler | None = None

predict

predict(df: DataFrame, ndvi_annual: Series, config: dict | None = None) -> dict
Parameters

df : DataFrame with FEATURE_COLS + ['deg_score', 'deg_class'] ndvi_annual : Annual mean NDVI Series (index = int years) config : optional dict; reads 'model_type' (default 'lgbm') and 'scale'

Returns

dict with keys: stats, charts

Source code in land_degradation/model.py
def predict(
    self,
    df: pd.DataFrame,
    ndvi_annual: pd.Series,
    config: dict | None = None,
) -> dict:
    """
    Parameters
    ----------
    df          : DataFrame with FEATURE_COLS + ['deg_score', 'deg_class']
    ndvi_annual : Annual mean NDVI Series (index = int years)
    config      : optional dict; reads 'model_type' (default 'lgbm') and 'scale'

    Returns
    -------
    dict with keys: stats, charts
    """
    cfg = config or {}
    model_type = cfg.get("model_type", "lgbm")
    scale = int(cfg.get("scale", 1000))

    if model_type not in VALID_MODEL_TYPES:
        raise ValueError(f"model_type must be one of {VALID_MODEL_TYPES}, got '{model_type}'")

    X = df[FEATURE_COLS].fillna(df[FEATURE_COLS].median()).to_numpy(dtype=np.float64)
    y = df["deg_class"].to_numpy(dtype=np.intp)

    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=42, stratify=_safe_stratify(y)
    )
    self.scaler = StandardScaler()
    X_train_s = self.scaler.fit_transform(X_train)
    X_test_s = self.scaler.transform(X_test)

    # Train RF and LightGBM concurrently when the Dask cluster is running,
    # otherwise fall back to sequential execution (e.g. during unit tests).
    from climate_change.core.dask_engine import DaskEngine

    client = DaskEngine.get_client_if_running()
    if client is not None:
        f_rf = client.submit(train_rf, X_train_s, y_train, pure=False)
        f_lgbm = client.submit(train_lgbm, X_train_s, y_train, pure=False)
        (self.rf, rf_meta), (self.lgbm, lgbm_meta) = cast(list, client.gather([f_rf, f_lgbm]))
    else:
        self.rf, rf_meta = train_rf(X_train_s, y_train)
        self.lgbm, lgbm_meta = train_lgbm(X_train_s, y_train)

    assert self.rf is not None
    assert self.lgbm is not None

    eval_result = evaluate_models(self.rf, self.lgbm, X_test_s, y_test)
    shap_model = self.rf if model_type == "rf" else self.lgbm
    shap_payload = compute_shap_importance(shap_model, X_test_s)
    trend_stats = compute_ndvi_trend(ndvi_annual)

    charts = build_degradation_charts(
        eval_result, shap_payload, ndvi_annual, trend_stats, model_type, scale
    )

    _KEY = {"rf": "rf", "lgbm": "lgbm", "ensemble": "ensemble"}
    result_key = _KEY.get(model_type, "lgbm")

    stats = {
        "model_type": model_type,
        "n_pixels_sampled": int(len(df)),
        "degraded_label_pct": round(float(y.mean() * 100), 1),
        "rf_cv_f1": round(rf_meta["cv_f1_mean"], 4)
        if rf_meta["cv_f1_mean"] is not None
        else None,
        "rf_f1": eval_result["rf"]["f1"],
        "rf_accuracy": eval_result["rf"]["accuracy"],
        "lgbm_cv_f1": round(lgbm_meta["cv_f1_mean"], 4)
        if lgbm_meta["cv_f1_mean"] is not None
        else None,
        "lgbm_f1": eval_result["lgbm"]["f1"],
        "lgbm_accuracy": eval_result["lgbm"]["accuracy"],
        "ensemble_f1": eval_result["ensemble"]["f1"],
        "selected_f1": eval_result[result_key]["f1"],
        "top_degradation_driver": shap_payload["features"][0],
        **trend_stats,
    }

    X_all_s = self.scaler.transform(
        df[FEATURE_COLS].fillna(df[FEATURE_COLS].median()).to_numpy(dtype=np.float64)
    )
    if model_type == "rf":
        all_preds = np.asarray(self.rf.predict(X_all_s)).astype(int)
    elif model_type == "lgbm":
        all_preds = np.asarray(self.lgbm.predict(X_all_s)).astype(int)
    else:
        rf_preds = np.asarray(self.rf.predict(X_all_s)).astype(int)
        lgbm_preds = np.asarray(self.lgbm.predict(X_all_s)).astype(int)
        all_preds = ((rf_preds + lgbm_preds) >= 1).astype(int)

    _DEG_CLASS_NAMES = ["Not Degraded", "Degraded"]
    if "lon" in df.columns and "lat" in df.columns:
        _sample_points = [
            {
                "lon": round(cast(float, df["lon"].iat[i]), 5),
                "lat": round(cast(float, df["lat"].iat[i]), 5),
                "risk_class": _DEG_CLASS_NAMES[int(all_preds[i])],
            }
            for i in range(len(df))
        ]
    else:
        _sample_points = []
    return {"stats": stats, "charts": charts, "_sample_points": _sample_points}

train_rf

train_rf(X_train: ndarray, y_train: ndarray, cv_folds: int = 5) -> tuple[RandomForestClassifier, dict]

Fit a balanced Random Forest and report CV weighted F1. Returns (model, metadata).

Source code in land_degradation/model.py
def train_rf(
    X_train: np.ndarray,
    y_train: np.ndarray,
    cv_folds: int = 5,
) -> tuple[RandomForestClassifier, dict]:
    """Fit a balanced Random Forest and report CV weighted F1. Returns (model, metadata)."""
    rf = RandomForestClassifier(
        n_estimators=RF_N_ESTIMATORS,
        max_depth=RF_MAX_DEPTH,
        min_samples_leaf=RF_MIN_SAMPLES_LEAF,
        class_weight="balanced",
        random_state=42,
        n_jobs=-1,
    )
    rf.fit(X_train, y_train)
    folds = _safe_cv_folds(y_train, cv_folds)
    if folds is None:
        return rf, {"cv_f1_mean": None, "cv_f1_std": None}
    cv = StratifiedKFold(n_splits=folds, shuffle=True, random_state=42)
    cv_f1 = cross_val_score(rf, X_train, y_train, cv=cv, scoring="f1_weighted")
    return rf, {"cv_f1_mean": float(cv_f1.mean()), "cv_f1_std": float(cv_f1.std())}

train_lgbm

train_lgbm(X_train: ndarray, y_train: ndarray, cv_folds: int = 5) -> tuple[lgb.LGBMClassifier, dict]

Fit a balanced LightGBM classifier and report CV weighted F1. Returns (model, metadata).

Source code in land_degradation/model.py
def train_lgbm(
    X_train: np.ndarray,
    y_train: np.ndarray,
    cv_folds: int = 5,
) -> tuple[lgb.LGBMClassifier, dict]:
    """Fit a balanced LightGBM classifier and report CV weighted F1. Returns (model, metadata)."""
    clf = FeatureNamedLGBMClassifier(
        n_estimators=LGBM_N_ESTIMATORS,
        learning_rate=LGBM_LR,
        num_leaves=LGBM_NUM_LEAVES,
        class_weight="balanced",
        random_state=42,
        n_jobs=-1,
        verbosity=-1,
    )
    X_train_df = pd.DataFrame(X_train, columns=FEATURE_COLS[: X_train.shape[1]])
    clf.fit(X_train_df, y_train)
    folds = _safe_cv_folds(y_train, cv_folds)
    if folds is None:
        return clf, {"cv_f1_mean": None, "cv_f1_std": None}
    cv = StratifiedKFold(n_splits=folds, shuffle=True, random_state=42)
    cv_f1 = cross_val_score(clf, X_train_df, y_train, cv=cv, scoring="f1_weighted")  # pyright: ignore[reportArgumentType]
    return clf, {"cv_f1_mean": float(cv_f1.mean()), "cv_f1_std": float(cv_f1.std())}

evaluate_models

evaluate_models(rf: RandomForestClassifier, lgbm: LGBMClassifier, X_test: ndarray, y_test: ndarray) -> dict

Evaluate RF, LightGBM, and majority-vote ensemble on the held-out test set.

Source code in land_degradation/model.py
def evaluate_models(
    rf: RandomForestClassifier,
    lgbm: lgb.LGBMClassifier,
    X_test: np.ndarray,
    y_test: np.ndarray,
) -> dict:
    """Evaluate RF, LightGBM, and majority-vote ensemble on the held-out test set."""
    rf_pred = np.asarray(rf.predict(X_test)).astype(int)
    lgbm_pred = np.asarray(lgbm.predict(X_test)).astype(int)
    ens_pred = ((rf_pred + lgbm_pred) >= 1).astype(int)

    def _metrics(pred: np.ndarray, label: str) -> dict:
        return {
            "label": label,
            "f1": round(float(f1_score(y_test, pred, average="weighted")), 4),
            "accuracy": round(float(accuracy_score(y_test, pred)), 4),
            "predictions": pred.tolist(),
        }

    return {
        "rf": _metrics(rf_pred, "Random Forest"),
        "lgbm": _metrics(lgbm_pred, "LightGBM"),
        "ensemble": _metrics(ens_pred, "Ensemble (majority vote)"),
        "actuals": y_test.tolist(),
    }

compute_shap_importance

compute_shap_importance(model: RandomForestClassifier | LGBMClassifier, X_test: ndarray) -> dict

TreeExplainer SHAP values sorted by descending mean |SHAP|. For multi-output (RF), averages across classes.

Source code in land_degradation/model.py
def compute_shap_importance(
    model: RandomForestClassifier | lgb.LGBMClassifier,
    X_test: np.ndarray,
) -> dict:
    """
    TreeExplainer SHAP values sorted by descending mean |SHAP|.
    For multi-output (RF), averages across classes.
    """
    import shap

    X_df = pd.DataFrame(X_test, columns=FEATURE_COLS)
    explainer = shap.TreeExplainer(model)
    shap_vals = explainer.shap_values(X_df)

    arr = np.array(shap_vals) if isinstance(shap_vals, list) else shap_vals
    # arr may be (n_classes, n_samples, n_features) or (n_samples, n_features)
    mean_abs = np.abs(arr).mean(axis=tuple(range(arr.ndim - 1)))

    rank_idx = np.argsort(mean_abs)[::-1]
    return {
        "features": [FEATURE_COLS[i] for i in rank_idx],
        "mean_abs_shap": mean_abs[rank_idx].round(4).tolist(),
    }

compute_ndvi_trend

compute_ndvi_trend(ndvi_annual: Series) -> dict

OLS linear regression + Mann-Kendall test + Binseg RBF breakpoint detection on an annual NDVI series (index = integer years). Returns a flat dict of trend statistics for inclusion in the result payload.

Source code in land_degradation/model.py
def compute_ndvi_trend(ndvi_annual: pd.Series) -> dict:
    """
    OLS linear regression + Mann-Kendall test + Binseg RBF breakpoint detection
    on an annual NDVI series (index = integer years).
    Returns a flat dict of trend statistics for inclusion in the result payload.
    """
    # Drop NaN and align years to the *actual* non-missing entries so that
    # gaps mid-series (cloud cover, data outages) do not shift the year
    # axis relative to the value array.
    valid = ndvi_annual.dropna()
    years = valid.index.values.astype(float)
    vals = np.asarray(valid.values)

    # A short/unusual date range (or an AOI where most years drop out via
    # dropna() above due to cloud cover) can leave fewer than 2 valid annual
    # points. linregress/kendalltau silently return NaN rather than raising
    # for <2 points, which would otherwise poison the JSON response — return
    # None for the undefined stats instead.
    if len(years) < 2:
        return {
            "ndvi_trend_per_year": None,
            "ndvi_trend_r2": None,
            "ndvi_trend_p": None,
            "mk_tau": None,
            "mk_p": None,
            "mk_significant": False,
            "breakpoint_years": [],
            "breakpoint_year": None,
        }

    _ols = stats.linregress(years, vals)
    ols_slope = cast(float, _ols[0])
    ols_rvalue = cast(float, _ols[2])
    ols_pvalue = cast(float, _ols[3])
    _mk = stats.kendalltau(years, vals)
    mk_tau = cast(float, _mk[0])
    mk_p = cast(float, _mk[1])

    # Binseg breakpoints — jump=1 ensures every year is a candidate
    signal = vals.reshape(-1, 1)
    n_obs = len(vals)
    min_size = 2
    jump = 1

    # sanity_check(n_obs, k, jump, min_size) is False for every k when n_obs
    # is too small for even 1 breakpoint (Binseg needs n_obs >= (k+1)*min_size).
    # The `next(..., 1)` fallback below must be 0 in that case — falling back
    # to 1 regardless would call binseg.predict(n_bkps=1) on a segmentation
    # sanity_check already rejected, raising ruptures.BadSegmentationParameters.
    n_bkps = next(
        (k for k in range(3, 0, -1) if sanity_check(n_obs, k, jump, min_size)),
        0,
    )
    if n_bkps == 0:
        bkp_years: list[int] = []
    else:
        binseg = rpt.Binseg(model="rbf", min_size=min_size, jump=jump).fit(signal)
        bkps_raw = binseg.predict(n_bkps=n_bkps)
        # bkps_raw indices reference the *valid* (post-dropna) array, so map
        # back through valid.index (not the original full index).
        valid_idx = valid.index.tolist()
        bkp_years = [int(valid_idx[i - 1]) for i in bkps_raw[:-1]]

    return {
        "ndvi_trend_per_year": round(float(ols_slope), 5),
        "ndvi_trend_r2": round(float(ols_rvalue**2), 4),
        "ndvi_trend_p": round(float(ols_pvalue), 4),
        "mk_tau": round(float(mk_tau), 4),
        "mk_p": round(float(mk_p), 4),
        "mk_significant": bool(float(mk_p) < 0.05),
        "breakpoint_years": bkp_years,
        "breakpoint_year": bkp_years[0] if bkp_years else None,
    }

build_degradation_charts

build_degradation_charts(eval_result: dict, shap_payload: dict, ndvi_annual: Series, trend_stats: dict, model_type: str = 'lgbm', scale: int = 1000) -> dict

Assemble frontend-ready chart payloads matching LandDegradationUseCase.run() schema.

Source code in land_degradation/model.py
def build_degradation_charts(
    eval_result: dict,
    shap_payload: dict,
    ndvi_annual: pd.Series,
    trend_stats: dict,
    model_type: str = "lgbm",
    scale: int = 1000,
) -> dict:
    """Assemble frontend-ready chart payloads matching LandDegradationUseCase.run() schema."""
    _KEY = {"rf": "rf", "lgbm": "lgbm", "ensemble": "ensemble"}
    result_key = _KEY.get(model_type, "lgbm")
    predictions = np.array(eval_result[result_key]["predictions"])
    actuals = np.array(eval_result["actuals"])

    n_total = len(actuals)
    pixel_ha = (scale**2) / 10_000
    not_deg_cnt = int((predictions == 0).sum())
    deg_cnt = int((predictions == 1).sum())

    return {
        "riskDist": {
            "labels": DEGRADATION_CLASSES,
            "data": [
                round(not_deg_cnt / n_total * 100, 1),
                round(deg_cnt / n_total * 100, 1),
            ],
            "data_ha": [round(not_deg_cnt * pixel_ha, 1), round(deg_cnt * pixel_ha, 1)],
            "colors": DEGRADATION_COLORS,
        },
        "timeSeries": {
            "labels": ndvi_annual.index.tolist(),
            "datasets": [
                {
                    "label": "Annual NDVI",
                    "data": ndvi_annual.round(4).tolist(),
                    "color": "#27AE60",
                },
            ],
        },
        "shap": shap_payload,
        "trend": trend_stats,
        "model_performance": {
            "rf": {
                "f1": eval_result["rf"]["f1"],
                "accuracy": eval_result["rf"]["accuracy"],
            },
            "lgbm": {
                "f1": eval_result["lgbm"]["f1"],
                "accuracy": eval_result["lgbm"]["accuracy"],
            },
            "ensemble": {
                "f1": eval_result["ensemble"]["f1"],
                "accuracy": eval_result["ensemble"]["accuracy"],
            },
            "selected": model_type,
        },
    }

COG export

cog_export

export_degradation_cog

export_degradation_cog(rf_model: RandomForestClassifier, lgbm_model: LGBMClassifier, scaler: StandardScaler, datasets: dict[str, Dataset], output_dir: str = 'outputs', prefix: str = 'land_degradation', model_type: str = 'lgbm', aoi_geojson: dict | None = None, scale: int = 1000) -> DegradationCogResult

Apply the trained model to the full pixel grid and write a Cloud-Optimised GeoTIFF.

Prediction values

0 = Not Degraded 1 = Degraded -1 = NoData (pixels with missing feature values)

Returns dict with keys

'degradation_risk' → path string 'risk_pct' → [not_degraded_pct, degraded_pct] over the AOI's valid (non-nodata) pixels, matching DEGRADATION_CLASSES order. 'risk_ha' → same two classes in hectares (pixel area = scale²/10_000)

This reflects every pixel actually painted on the map, unlike the training-sample-based stats LandDegradationModel.predict() computes, which only covers the held-out test split and can diverge sharply from the full-AOI distribution.

Source code in land_degradation/cog_export.py
def export_degradation_cog(
    rf_model: RandomForestClassifier,
    lgbm_model: lgb.LGBMClassifier,
    scaler: StandardScaler,
    datasets: dict[str, xr.Dataset],
    output_dir: str = "outputs",
    prefix: str = "land_degradation",
    model_type: str = "lgbm",
    aoi_geojson: dict | None = None,
    scale: int = 1000,
) -> DegradationCogResult:
    """
    Apply the trained model to the full pixel grid and write a Cloud-Optimised GeoTIFF.

    Prediction values:
      0  = Not Degraded
      1  = Degraded
      -1 = NoData (pixels with missing feature values)

    Returns dict with keys:
      'degradation_risk' → path string
      'risk_pct'         → [not_degraded_pct, degraded_pct] over the AOI's valid
                           (non-nodata) pixels, matching DEGRADATION_CLASSES order.
      'risk_ha'          → same two classes in hectares (pixel area = scale²/10_000)
    This reflects every pixel actually painted on the map, unlike the
    training-sample-based stats LandDegradationModel.predict() computes, which
    only covers the held-out test split and can diverge sharply from the
    full-AOI distribution.
    """
    out = Path(output_dir)
    out.mkdir(parents=True, exist_ok=True)

    aligned = align_datasets(datasets, ref_key="ndvi")
    ref_ds = aligned["ndvi"]
    lat = ref_ds.lat.values
    lon = ref_ds.lon.values
    nlat = len(lat)
    nlon = len(lon)
    n_px = nlat * nlon

    # Build feature matrix in FEATURE_COLS order
    feat_arrays: dict[str, np.ndarray] = {}
    for _key, ds in aligned.items():
        for var in ds.data_vars:
            col = "land_cover" if var == "Map" else str(var)
            if col in FEATURE_COLS:
                feat_arrays[col] = ds[var].values.ravel()

    X = np.column_stack([feat_arrays.get(c, np.full(n_px, np.nan)) for c in FEATURE_COLS])
    valid_mask = ~np.any(np.isnan(X), axis=1)
    # "land_cover" here holds raw ESA WorldCover class codes — exclude water bodies.
    water = np.isclose(feat_arrays.get("land_cover", np.full(n_px, np.nan)), WATER_CLASS)
    valid_mask &= ~water
    X_valid = scaler.transform(X[valid_mask])

    if model_type == "rf":
        pred_valid = np.asarray(rf_model.predict(X_valid)).astype(np.int8)
    elif model_type == "lgbm":
        pred_valid = np.asarray(lgbm_model.predict(X_valid)).astype(np.int8)
    else:
        rf_pred = np.asarray(rf_model.predict(X_valid)).astype(int)
        lgbm_pred = np.asarray(lgbm_model.predict(X_valid)).astype(int)
        pred_valid = ((rf_pred + lgbm_pred) >= 1).astype(np.int8)

    prediction = np.full(n_px, -1, dtype=np.int8)
    prediction[valid_mask] = pred_valid
    prediction_2d = prediction.reshape(nlat, nlon)
    transform = ref_ds.rio.transform()
    if aoi_geojson:
        inside_aoi = geometry_mask(
            [aoi_geojson],
            out_shape=(nlat, nlon),
            transform=transform,
            invert=True,
        )
        prediction_2d[~inside_aoi] = -1

    da = xr.DataArray(
        prediction_2d,
        dims=["lat", "lon"],
        coords={"lat": lat, "lon": lon},
        name="degradation_class",
    )
    da.attrs.update({"long_name": "Degradation class (0=Not Degraded, 1=Degraded)", "nodata": -1})
    da = da.rio.set_spatial_dims(x_dim="lon", y_dim="lat")
    da = da.rio.write_crs("EPSG:4326")
    da = da.rio.set_nodata(-1)

    cog_path = out / f"{prefix}_degradation_risk.tif"
    da.rio.to_raster(str(cog_path), driver="COG", compress="LZW")

    valid_pred = prediction_2d[prediction_2d >= 0]
    n_valid = int(valid_pred.size)
    not_deg_count = int((valid_pred == 0).sum())
    deg_count = int((valid_pred == 1).sum())
    risk_pct = (
        [round(not_deg_count / n_valid * 100, 1), round(deg_count / n_valid * 100, 1)]
        if n_valid
        else [0.0, 0.0]
    )
    pixel_ha = (scale**2) / 10_000
    risk_ha = [round(not_deg_count * pixel_ha, 1), round(deg_count * pixel_ha, 1)]

    population_by_class: dict[str, float] = {}
    total_population: float | None = None
    pop_ds = aligned.get("population_count")
    if pop_ds is not None:
        by_int = population_exposure(prediction_2d, transform, pop_ds, classes=[0, 1])
        population_by_class = {DEGRADATION_CLASSES[v]: by_int[str(v)] for v in (0, 1)}
        total_population = round(sum(population_by_class.values()), 1)

    return {
        "degradation_risk": str(cog_path),
        "risk_pct": risk_pct,
        "risk_ha": risk_ha,
        "population_by_class": population_by_class,
        "total_population": total_population,
    }