Appendix F — Cameroon Flood Snapshots

Analysis of October & November 2024 flooding events in Cameroon using GFM (Global Flood Monitoring) satellite data. We compare GFM-derived affected population estimates against FloodScan and CEMS reference data.

Code
import pystac_client
import stackstac
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap, BoundaryNorm
import numpy as np
import geopandas as gpd
from fsspec.implementations.http import HTTPFileSystem
from ds_flood_gfm.geo_utils import load_adm0_lowres
import time
import pandas as pd

import ocha_stratus as stratus
from datetime import datetime, timedelta
from exactextract import exact_extract
# Configure matplotlib for better display
%matplotlib inline
plt.rcParams['figure.dpi'] = 100
Code
# Analysis configuration
notebook_config = {
    'combined_range': {
        'start': '2024-10-14',
        'end': '2024-11-30'
    },
    'october': {
        'name': 'October 2024 Floods',
        'start': '2024-10-14',
        'end': '2024-10-30',
        'snapshot_dates': ['2024-10-18', '2024-10-25'],
        'reference_date': '2024-10-18'
    },
    'november': {
        'name': 'November 2024 Floods',
        'start': '2024-11-13',
        'end': '2024-11-30',
        'snapshot_dates': ['2024-11-23', '2024-11-28'],
        'reference_date': '2024-11-18'
    }
}

F.1 Area of Interest

Analysis focuses on Admin 1 region CM004 in Cameroon, with three custom AOI polygons defined by Copernicus Emergency Management Service (EMSR772 & EMSR779).

Code
GLOBAL_ADM1 = (
    "https://data.fieldmaps.io/edge-matched/humanitarian/intl/adm1_polygons.parquet"
)
ISO3 = "CMR"
filesystem = HTTPFileSystem()
filters = [("iso_3", "=", ISO3)]
gdf = gpd.read_parquet(GLOBAL_ADM1, filesystem=filesystem, filters=filters)
gdf_aoi = gdf[gdf.adm1_src == "CM004"]
bbox = gdf_aoi.total_bounds
Code
gdf_world_adm0 = load_adm0_lowres()
gdf_subset_admo = gdf_world_adm0[gdf_world_adm0.name == "Cameroon"]
Code
# load specific aoi used by copernicus
laoi = []
for aoi_num in range(1, 4):
    _blob_name = f"ds-cmr-flooding-support/raw/copernicus/EMSR772_AOI{aoi_num:02d}_DEL_PRODUCT_v1.zip"
    _shapefile = f"EMSR772_AOI{aoi_num:02d}_DEL_PRODUCT_areaOfInterestA_v1.shp"
    aoi_gdf = stratus.load_shp_from_blob(
        blob_name=_blob_name, shapefile=_shapefile, container_name="projects"
    )
    laoi.append(aoi_gdf)

gdf_aoi_copernicus = gpd.pd.concat(laoi, ignore_index=True)

F.2 STAC Query

Query GFM flood extent data from EODC STAC API for the combined date range (Oct 14 - Nov 30, 2024).

Code
stac_api = "https://stac.eodc.eu/api/v1"
client = pystac_client.Client.open(stac_api)

# Query for combined date range covering both October and November events
start_date = notebook_config['combined_range']['start']
end_date = notebook_config['combined_range']['end']

# Step 2: Search for GFM items
datetime_range = f"{start_date}/{end_date}"
search = client.search(
    collections=["GFM"],
    bbox=gdf_aoi.total_bounds,
    datetime=datetime_range,    
)

item_collection = search.item_collection()
Code
stack = stackstac.stack(item_collection, epsg=4326)

F.3 Pre-Processing

F.3.1 GFM Data (STAC) Lazy

Extract flood extent band and create daily composites by taking the maximum flood value per day across overlapping tiles.

Code
stack_flood = stack.sel(band="ensemble_flood_extent")

stack_flood_clipped = stack_flood.sel(
x=slice(bbox[0], bbox[2]), y=slice(bbox[3], bbox[1])  # y is reversealri
)

# Group by day and take the maximum flood value for each day
# Use groupby instead of resample to only get days that actually have data
stack_flood_max = stack_flood_clipped.groupby("time.date").max()

# Rename the dimension back to 'time' and convert to datetime
stack_flood_max = stack_flood_max.rename({"date": "time"})
stack_flood_max["time"] = stack_flood_max.time.astype("datetime64[ns]")

Split the combined dataset into October and November events before applying forward-fill. This is more efficient since forward-fill is a sequential operation that processes all timesteps.

Code
# Split into individual events using config dates (before ffill for performance)
oct_stack_flood_max = stack_flood_max.sel(
    time=slice(notebook_config['october']['start'], notebook_config['october']['end'])
)
nov_stack_flood_max = stack_flood_max.sel(
    time=slice(notebook_config['november']['start'], notebook_config['november']['end'])
)

# Apply forward-fill separately to each event (much more efficient)
oct_ic_latest = oct_stack_flood_max.ffill(dim="time")
nov_ic_latest = nov_stack_flood_max.ffill(dim="time")

F.3.2 Population Data (GHSL)

Load GHSL population data to overlay. Here we are using the estimated 2025 pop produced in 2023.

  • We load it just for the AOI bbox, then clip it down further.
Code
blob_name = "ghsl/pop/GHS_POP_E2025_GLOBE_R2023A_4326_3ss_V1_0.tif"
da_global = stratus.open_blob_cog(blob_name, container_name="raster").squeeze(drop=True)

# Ensure the global data has the correct CRS
# da_global = da_global.rio.write_crs("EPSG:4326")
da_global.rio.crs

# clip to box (need to do this first, otherwise Python crashes on normal .rio.clip)
min_x, min_y, max_x, max_y = gdf_aoi.total_bounds
da_clip_box = da_global.rio.clip_box(minx=min_x, miny=min_y, maxx=max_x, maxy=max_y)

# clip to admin and preserve CRS
da_clip = da_clip_box.rio.clip(gdf_aoi.geometry)
da_clip = da_clip.rio.write_crs("EPSG:4326")
da_clip = da_clip.where(da_clip != da_clip.rio.nodata)

Here we show the GHSL population raster for the admin 1 of interest (grey outline) and the specific custom AOI’s from CEMS (black outline).

Code
# Create a map of the clipped population data
fig, ax = plt.subplots(1, 1, figsize=(12, 8))

# Plot the population data
im = da_clip.plot(
    ax=ax,
    cmap='YlOrRd',
    vmin=0,
    vmax=200,
    add_colorbar=False
)

# Add colorbar
cbar = plt.colorbar(im, ax=ax, label='Population Count')

# Add admin boundary in grey
gdf_aoi.boundary.plot(ax=ax, color='grey', linewidth=2, alpha=0.8)

# Add Copernicus AOI boundaries in black
gdf_aoi_copernicus.boundary.plot(ax=ax, color='black', linewidth=2, alpha=1.0)

# Set title and labels
ax.set_title('Population Density 2025 - Clipped to AOI', fontsize=12, fontweight='bold')
ax.set_xlabel('Longitude')
ax.set_ylabel('Latitude')

plt.tight_layout()
plt.show()

F.4 Overlay Flood Extent & Population Data

Here we follow the GFM methodology as closely as possible. We need to get the data on comparable grids: - We take the 100m population data and resample it to the 20m flood data and rescale the population numbers to reflect this. - We then run zonal statistics (using exact_extract)

Code
def gfm_exact_workflow_zonal(flood_20m, population_100m, gdf_zones, zone_id_col=None):
    """
    Calculate affected population using GFM methodology with zonal statistics.

    Parameters
    ----------
    flood_20m : xr.DataArray
        Binary flood mask at 20m resolution (0 or 1)
    population_100m : xr.DataArray
        Population density at 100m resolution
    gdf_zones : gpd.GeoDataFrame
        Polygons for zonal statistics (e.g., multiple admin areas)
    zone_id_col : str, optional
        Column name to use as zone identifier

    Returns
    -------
    pd.DataFrame
        Results with affected_population per zone
    """

    print("=" * 80)
    print("GFM Exact Workflow with Zonal Statistics (exactextract)")
    print("=" * 80)

    print(f"\nNumber of zones: {len(gdf_zones)}")

    # Ensure CRS match
    if gdf_zones.crs != flood_20m.rio.crs:
        print(f"Reprojecting zones from {gdf_zones.crs} to {flood_20m.rio.crs}")
        gdf_zones = gdf_zones.to_crs(flood_20m.rio.crs)

    # Clean population nodata
    if population_100m.rio.nodata is not None:
        print(f"Clearing nodata metadata (was: {population_100m.rio.nodata})")
        population_100m = population_100m.rio.write_nodata(None)

    # Resample population to 20m
    print("Upsampling population from 100m to 20m...")
    population_20m = population_100m.rio.reproject_match(
        flood_20m,
        resampling=0  # nearest neighbor
    )

    # Adjust for pixel area (divide by 25)
    print("Adjusting population density for resolution change...")
    pixel_area_ratio = (100 / 20) ** 2  # = 25
    population_20m_adjusted = population_20m / pixel_area_ratio

    print(f"  Population adjustment factor: {pixel_area_ratio}")
    print(f"  Original sum: {population_100m.sum(skipna=True).values:,.0f}")
    print(f"  After adjustment: {population_20m_adjusted.sum(skipna=True).values:,.0f}")

    # Calculate affected population
    print("Calculating affected population at 20m resolution...")
    affected_pop_20m = flood_20m * population_20m_adjusted

    # Zonal statistics using exactextract
    print(f"Computing zonal statistics for {len(gdf_zones)} zones...")

    # Prepare zone identifier
    if zone_id_col and zone_id_col in gdf_zones.columns:
        zone_ids = gdf_zones[zone_id_col].tolist()
    else:
        zone_ids = gdf_zones.index.tolist()

    # Extract zonal sums for affected population
    results = exact_extract(
        affected_pop_20m,
        gdf_zones,
        'sum',
        output='pandas'
    )

    # Extract flood extent statistics
    flood_results = exact_extract(
        flood_20m,
        gdf_zones,
        ['sum', 'count'],
        output='pandas'
    )

    # Combine results
    results_df = pd.DataFrame({
        'zone_id': zone_ids,
        'affected_population': results['sum'].values,
        'flooded_pixels': flood_results['sum'].values,
        'total_pixels': flood_results['count'].values,
    })

    # Calculate flooded area
    pixel_area_m2 = 20 * 20  # 20m resolution
    results_df['flooded_area_m2'] = results_df['flooded_pixels'] * pixel_area_m2
    results_df['flooded_area_km2'] = results_df['flooded_area_m2'] / 1_000_000

    # Add zone names if available
    if zone_id_col and zone_id_col in gdf_zones.columns:
        results_df['zone_name'] = gdf_zones[zone_id_col].values

    # Clean up values
    results_df['affected_population'] = results_df['affected_population'].fillna(0).astype(int)
    results_df['flooded_pixels'] = results_df['flooded_pixels'].fillna(0).astype(int)

    print("\n" + "=" * 80)
    print("RESULTS BY ZONE")
    print("=" * 80)
    print(results_df.to_string(index=False))

    return results_df
Code
def map_flood_extent_snapshots(flood_data_computed, snapshot_dates, gdf_boundaries, event_name):
    """
    Create side-by-side flood extent maps for snapshot dates.

    Parameters
    ----------
    flood_data_computed : xr.DataArray
        Computed flood data array (already filtered and computed)
    snapshot_dates : list
        List of snapshot dates being visualized
    gdf_boundaries : gpd.GeoDataFrame
        Boundaries to overlay on maps
    event_name : str
        Event name for plot context

    Returns
    -------
    None
        Displays matplotlib figure
    """
    # Define colors for flood data visualization
    colors = ["lightgrey", "darkblue", "white"]  # 0=no flood, 1=flood, 255=nodata
    cmap = ListedColormap(colors)
    bounds = [0, 0.5, 1.5, 255.5]
    norm = BoundaryNorm(bounds, cmap.N)

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(20, 8))

    # Process each time step
    for i, ax in enumerate([ax1, ax2]):
        time_step = flood_data_computed.isel(time=i)
        date_str = str(time_step.time.values)[:10]

        # Process data (handle NaN and downsample for visualization)
        data_clean = np.where(np.isnan(time_step), 255, time_step)
        data_clean = data_clean[::4, ::4]  # Downsample for speed

        # Get extent from the xarray data
        bounds_extent = time_step.rio.bounds()
        extent = [bounds_extent[0], bounds_extent[2], bounds_extent[1], bounds_extent[3]]

        # Plot the flood data
        im = ax.imshow(
            data_clean,
            cmap=cmap,
            norm=norm,
            interpolation="nearest",
            origin="upper",
            extent=extent,
        )

        # Overlay boundaries
        gdf_boundaries.boundary.plot(ax=ax, color='red', linewidth=3, alpha=0.8)

        # Set title and labels
        ax.set_title(f'{event_name}\nFlood Extent - {date_str}',
                    fontsize=12, fontweight='bold')
        ax.set_xlabel('Longitude')
        ax.set_ylabel('Latitude')

    plt.tight_layout()
    plt.show()
Code
def create_comparison_barplot(gfm_results, other_systems_data, event_name,
                              systems=['CEMS', 'FloodScan', 'GFM (STAC)']):
    """
    Create comparison bar plot of affected population across systems.

    Parameters
    ----------
    gfm_results : pd.DataFrame
        GFM zonal statistics results with columns: zone_id, affected_population
    other_systems_data : dict
        Dict of system data, e.g., {'FloodScan': {'AOI 1': 7580, ...}, 'CEMS': {...}}
    event_name : str
        Event name for plot title
    systems : list
        List of systems to include in plot (e.g., ['CEMS', 'GFM (STAC)'])

    Returns
    -------
    None
        Displays matplotlib figure and prints comparison stats
    """
    # Convert other systems data to DataFrame
    comparison_data = []
    for system, values in other_systems_data.items():
        for aoi, pop in values.items():
            comparison_data.append({
                'AOI': aoi,
                'System': system,
                'affected_population': pop
            })

    df_other_systems = pd.DataFrame(comparison_data)

    # Prepare GFM data
    gfm_comparison = gfm_results[['zone_id', 'affected_population']].copy()
    gfm_comparison['System'] = 'GFM (STAC)'

    # Map zone_id to standard AOI format
    zone_id_to_aoi = {}
    for idx, zone_id in enumerate(gfm_comparison['zone_id']):
        if isinstance(zone_id, (int, np.integer)):
            zone_id_to_aoi[zone_id] = f"AOI {zone_id + 1}"
        elif 'AOI' in str(zone_id).upper():
            import re
            match = re.search(r'AOI(\d+)', str(zone_id), re.IGNORECASE)
            if match:
                aoi_num = int(match.group(1))
                zone_id_to_aoi[zone_id] = f"AOI {aoi_num}"
            else:
                zone_id_to_aoi[zone_id] = f"AOI {idx + 1}"
        else:
            zone_id_to_aoi[zone_id] = f"AOI {idx + 1}"

    gfm_comparison['AOI'] = gfm_comparison['zone_id'].map(zone_id_to_aoi)
    gfm_comparison = gfm_comparison[['AOI', 'System', 'affected_population']]

    # Combine all data
    df_combined = pd.concat([df_other_systems, gfm_comparison], ignore_index=True)

    # Filter to requested systems
    df_combined = df_combined[df_combined['System'].isin(systems)]

    # Display comparison table
    print("\n" + "=" * 80)
    print(f"AFFECTED POPULATION COMPARISON - {event_name}")
    print("=" * 80)
    print(df_combined.pivot(index='AOI', columns='System', values='affected_population'))

    # Create comparison barplot
    fig, ax = plt.subplots(figsize=(12, 6))

    # Get unique AOIs and systems
    aois = sorted(df_combined['AOI'].unique())
    systems_plot = [s for s in systems if s in df_combined['System'].values]

    # Set up bar positions
    x = np.arange(len(aois))
    width = 0.8 / len(systems_plot)  # Adjust width based on number of systems

    # Define consistent colors
    color_map = {
        'GFM (STAC)': '#3cb371',      # Minty green
        'FloodScan': '#ff6347',        # Tomato red
        'CEMS': '#0f52ba'              # Sapphire blue
    }

    # Plot bars for each system
    for i, system in enumerate(systems_plot):
        system_data = df_combined[df_combined['System'] == system]
        values = [system_data[system_data['AOI'] == aoi]['affected_population'].values[0]
                  if len(system_data[system_data['AOI'] == aoi]) > 0 else 0
                  for aoi in aois]

        bars = ax.bar(x + i * width, values, width, label=system, color=color_map.get(system, 'gray'))

        # Add value labels on bars
        for bar, val in zip(bars, values):
            height = bar.get_height()
            ax.text(bar.get_x() + bar.get_width()/2., height,
                    f'{int(val):,}',
                    ha='center', va='bottom', fontsize=9)

    # Customize plot
    ax.set_xlabel('Area of Interest (AOI)', fontsize=12, fontweight='bold')
    ax.set_ylabel('Affected Population', fontsize=12, fontweight='bold')
    ax.set_title(f'Affected Population Comparison\n{event_name}', fontsize=14, fontweight='bold')
    ax.set_xticks(x + width * (len(systems_plot) - 1) / 2)
    ax.set_xticklabels(aois)
    ax.legend(title='System', loc='upper right', fontsize=11)
    ax.grid(axis='y', alpha=0.3, linestyle='--')

    # Format y-axis
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{int(x):,}'))

    plt.tight_layout()
    plt.show()

    # Print comparison ratios
    if 'GFM (STAC)' in systems_plot:
        print("\n" + "=" * 80)
        print("SYSTEM COMPARISON RATIOS (relative to GFM)")
        print("=" * 80)

        for aoi in aois:
            gfm_val = df_combined[(df_combined['AOI'] == aoi) & (df_combined['System'] == 'GFM (STAC)')]['affected_population'].values
            if len(gfm_val) > 0:
                gfm_val = gfm_val[0]
                print(f"\n{aoi}:")
                print(f"  GFM (baseline): {gfm_val:,}")

                for system in systems_plot:
                    if system != 'GFM (STAC)':
                        sys_val = df_combined[(df_combined['AOI'] == aoi) & (df_combined['System'] == system)]['affected_population'].values
                        if len(sys_val) > 0:
                            sys_val = sys_val[0]
                            ratio = sys_val / gfm_val if gfm_val > 0 else np.inf
                            print(f"  {system}: {sys_val:,} ({ratio:.2f}x GFM)")

F.5 Resulting Flood Extents & Affected Population Zonal Stats

Calculate affected population for the November event using the forward-filled flood stack.

Code
# Run zonal stats for November event using filled/forward-filled stack

flood_day_max = nov_stack_flood_max.sel(time=notebook_config['november']['snapshot_dates'])

flood_day_max_computed= flood_day_max.compute()
# Process each time step in flood_data_latest_computed
list_zonal = []

for i in range(len(flood_day_max_computed.time)):
    time_step = flood_day_max_computed.isel(time=i)
    date_str = str(time_step.time.values)[:10]
    print(f"\nProcessing {date_str}...")
    
    # Apply GFM threshold
    flood_day_max_binary = (time_step == 1).astype(int)
    flood_day_max_binary = flood_day_max_binary.rio.write_crs(time_step.rio.crs)
    
    # Run zonal analysis
    results_time = gfm_exact_workflow_zonal(
        flood_20m=flood_day_max_binary,
        population_100m=da_clip,
        gdf_zones=gdf_aoi_copernicus,
        zone_id_col='aoi_code'  # Adjust to match your column name
    )
    
    # Add date column to results
    results_time['date'] = date_str
    list_zonal.append(results_time)
    
    # Display results for this time step
    print(f"Total affected population for {date_str}: {results_time['affected_population'].sum():,}")

# Combine all results
day_max_zonal = pd.concat(list_zonal, ignore_index=True)
print(f"\nOverall results shape: {day_max_zonal.shape}")
day_max_zonal

Processing 2024-11-23...
================================================================================
GFM Exact Workflow with Zonal Statistics (exactextract)
================================================================================

Number of zones: 3
Clearing nodata metadata (was: -9999.0)
Upsampling population from 100m to 20m...
Adjusting population density for resolution change...
  Population adjustment factor: 25.0
  Original sum: 4,731,122
  After adjustment: 3,861,655
Calculating affected population at 20m resolution...
Computing zonal statistics for 3 zones...

================================================================================
RESULTS BY ZONE
================================================================================
 zone_id  affected_population  flooded_pixels  total_pixels  flooded_area_m2  flooded_area_km2
       0                    4           15008  1.667928e+07     6.003403e+06          6.003403
       1                  637          453587  1.353145e+07     1.814351e+08        181.435092
       2                  913          290626  1.970349e+07     1.162507e+08        116.250739
Total affected population for 2024-11-23: 1,554

Processing 2024-11-28...
================================================================================
GFM Exact Workflow with Zonal Statistics (exactextract)
================================================================================

Number of zones: 3
Clearing nodata metadata (was: -9999.0)
Upsampling population from 100m to 20m...
Adjusting population density for resolution change...
  Population adjustment factor: 25.0
  Original sum: 4,731,122
  After adjustment: 3,861,655
Calculating affected population at 20m resolution...
Computing zonal statistics for 3 zones...

================================================================================
RESULTS BY ZONE
================================================================================
 zone_id  affected_population  flooded_pixels  total_pixels  flooded_area_m2  flooded_area_km2
       0                    0               0  1.667928e+07     0.000000e+00          0.000000
       1                   14           66764  1.353145e+07     2.670565e+07         26.705654
       2                   89           56501  1.970349e+07     2.260061e+07         22.600609
Total affected population for 2024-11-28: 103

Overall results shape: (6, 7)
zone_id affected_population flooded_pixels total_pixels flooded_area_m2 flooded_area_km2 date
0 0 4 15008 1.667928e+07 6.003403e+06 6.003403 2024-11-23
1 1 637 453587 1.353145e+07 1.814351e+08 181.435092 2024-11-23
2 2 913 290626 1.970349e+07 1.162507e+08 116.250739 2024-11-23
3 0 0 0 1.667928e+07 0.000000e+00 0.000000 2024-11-28
4 1 14 66764 1.353145e+07 2.670565e+07 26.705654 2024-11-28
5 2 89 56501 1.970349e+07 2.260061e+07 22.600609 2024-11-28
Code
# Zonal statistics using forward-filled composites (November event)

flood_data_latest = nov_ic_latest.sel(time=notebook_config['november']['snapshot_dates'])

flood_data_latest_computed = flood_data_latest.compute()

results_filled = []
for i in range(len(flood_data_latest_computed.time)):
    time_step = flood_data_latest_computed.isel(time=i)
    date_str = str(time_step.time.values)[:10]
    print(f"\nProcessing {date_str}...")
    
    # Apply GFM threshold
    flood_binary = (time_step == 1).astype(int)
    flood_binary = flood_binary.rio.write_crs(time_step.rio.crs)
    
    # Run zonal analysis
    results_time = gfm_exact_workflow_zonal(
        flood_20m=flood_binary,
        population_100m=da_clip,
        gdf_zones=gdf_aoi_copernicus,
        zone_id_col='aoi_code'  # Adjust to match your column name
    )
    
    # Add date column to results
    results_time['date'] = date_str
    results_filled.append(results_time)
    
    # Display results for this time step
    print(f"Total affected population for {date_str}: {results_time['affected_population'].sum():,}")

# Combine all results
nov_zonal_results = pd.concat(results_filled, ignore_index=True)

# Store the latest date result for comparison
nov_latest_img_zonal_date = nov_zonal_results[nov_zonal_results["date"] == notebook_config['november']['snapshot_dates'][0]]

Processing 2024-11-23...
================================================================================
GFM Exact Workflow with Zonal Statistics (exactextract)
================================================================================

Number of zones: 3
Clearing nodata metadata (was: -9999.0)
Upsampling population from 100m to 20m...
Adjusting population density for resolution change...
  Population adjustment factor: 25.0
  Original sum: 4,731,122
  After adjustment: 3,861,655
Calculating affected population at 20m resolution...
Computing zonal statistics for 3 zones...

================================================================================
RESULTS BY ZONE
================================================================================
 zone_id  affected_population  flooded_pixels  total_pixels  flooded_area_m2  flooded_area_km2
       0                    4           15008  1.667928e+07     6.003403e+06          6.003403
       1                  637          453587  1.353145e+07     1.814351e+08        181.435092
       2                  913          290626  1.970349e+07     1.162507e+08        116.250739
Total affected population for 2024-11-23: 1,554

Processing 2024-11-28...
================================================================================
GFM Exact Workflow with Zonal Statistics (exactextract)
================================================================================

Number of zones: 3
Clearing nodata metadata (was: -9999.0)
Upsampling population from 100m to 20m...
Adjusting population density for resolution change...
  Population adjustment factor: 25.0
  Original sum: 4,731,122
  After adjustment: 3,861,655
Calculating affected population at 20m resolution...
Computing zonal statistics for 3 zones...

================================================================================
RESULTS BY ZONE
================================================================================
 zone_id  affected_population  flooded_pixels  total_pixels  flooded_area_m2  flooded_area_km2
       0                    4           15008  1.667928e+07     6.003403e+06          6.003403
       1                  627          422309  1.353145e+07     1.689236e+08        168.923632
       2                 1002          346647  1.970349e+07     1.386589e+08        138.658948
Total affected population for 2024-11-28: 1,633

F.5.1 Flood Extent Visualizations

Individual satellite observations can have gaps largely due to overpass timing. Here we see the visualization. Here we see the flood extent of two single days around one of our flood event dates. In many use-cases we’d be interested in checking out a date and then we’d find the closest image with coverage. You can see the data gap present on Nov 28. If we ran zonal statistics on that image we’d end up with some confusing and misleading results. The latest-image-composite shown below will give us more useful results.

Code
# Visualize flood extent for snapshot dates (individual observations only, no forward-fill)
map_flood_extent_snapshots(
    flood_data_computed=flood_day_max_computed,
    snapshot_dates=notebook_config['november']['snapshot_dates'],
    gdf_boundaries=gdf_aoi_copernicus,
    event_name=notebook_config['november']['name']
)

Code
# Visualize flood extent for snapshot dates (with forward-fill compositing)
map_flood_extent_snapshots(
    flood_data_computed=flood_data_latest_computed,
    snapshot_dates=notebook_config['november']['snapshot_dates'],
    gdf_boundaries=gdf_aoi_copernicus,
    event_name=notebook_config['november']['name']
)

F.5.2 System Comparisons

Code
# Hardcoded November 2024 reference data from other systems
nov_other_systems_data = {
    'FloodScan': {
        'AOI 1': 7580,
        'AOI 2': 110655,
        'AOI 3': 86461
    },
    'CEMS': {
        'AOI 1': 1500,
        'AOI 2': 13000,
        'AOI 3': 8200
    }
}

# Create comparison plot with all three systems
create_comparison_barplot(
    gfm_results=nov_latest_img_zonal_date,
    other_systems_data=nov_other_systems_data,
    event_name=notebook_config['november']['name'],
    systems=['CEMS', 'FloodScan', 'GFM (STAC)']
)

================================================================================
AFFECTED POPULATION COMPARISON - November 2024 Floods
================================================================================
System   CEMS  FloodScan  GFM (STAC)
AOI                                 
AOI 1    1500       7580           4
AOI 2   13000     110655         637
AOI 3    8200      86461         913


================================================================================
SYSTEM COMPARISON RATIOS (relative to GFM)
================================================================================

AOI 1:
  GFM (baseline): 4
  CEMS: 1,500 (375.00x GFM)
  FloodScan: 7,580 (1895.00x GFM)

AOI 2:
  GFM (baseline): 637
  CEMS: 13,000 (20.41x GFM)
  FloodScan: 110,655 (173.71x GFM)

AOI 3:
  GFM (baseline): 913
  CEMS: 8,200 (8.98x GFM)
  FloodScan: 86,461 (94.70x GFM)

F.6 October 2024 Event Analysis

F.6.1 Zonal Statistics

Code
# Run zonal stats for October event using filled/forward-filled stack

oct_flood_data_latest = oct_ic_latest.sel(time=notebook_config['october']['snapshot_dates'])
oct_flood_data_latest_computed = oct_flood_data_latest.compute()

oct_results_filled = []
for i in range(len(oct_flood_data_latest_computed.time)):
    time_step = oct_flood_data_latest_computed.isel(time=i)
    date_str = str(time_step.time.values)[:10]
    print(f"\nProcessing {date_str}...")

    # Apply GFM threshold
    flood_binary = (time_step == 1).astype(int)
    flood_binary = flood_binary.rio.write_crs(time_step.rio.crs)

    # Run zonal analysis
    results_time = gfm_exact_workflow_zonal(
        flood_20m=flood_binary,
        population_100m=da_clip,
        gdf_zones=gdf_aoi_copernicus,
        zone_id_col='aoi_code'
    )

    # Add date column to results
    results_time['date'] = date_str
    oct_results_filled.append(results_time)

    # Display results for this time step
    print(f"Total affected population for {date_str}: {results_time['affected_population'].sum():,}")

# Combine all results
oct_zonal_results = pd.concat(oct_results_filled, ignore_index=True)

# Store the latest date result for comparison
oct_latest_img_zonal_date = oct_zonal_results[oct_zonal_results["date"] == notebook_config['october']['snapshot_dates'][0]]

Processing 2024-10-18...
================================================================================
GFM Exact Workflow with Zonal Statistics (exactextract)
================================================================================

Number of zones: 3
Clearing nodata metadata (was: -9999.0)
Upsampling population from 100m to 20m...
Adjusting population density for resolution change...
  Population adjustment factor: 25.0
  Original sum: 4,731,122
  After adjustment: 3,861,655
Calculating affected population at 20m resolution...
Computing zonal statistics for 3 zones...

================================================================================
RESULTS BY ZONE
================================================================================
 zone_id  affected_population  flooded_pixels  total_pixels  flooded_area_m2  flooded_area_km2
       0                  325          130838  1.667928e+07     5.233527e+07         52.335274
       1                  511          287147  1.353145e+07     1.148589e+08        114.858881
       2                  772          174955  1.970349e+07     6.998233e+07         69.982328
Total affected population for 2024-10-18: 1,608

Processing 2024-10-25...
================================================================================
GFM Exact Workflow with Zonal Statistics (exactextract)
================================================================================

Number of zones: 3
Clearing nodata metadata (was: -9999.0)
Upsampling population from 100m to 20m...
Adjusting population density for resolution change...
  Population adjustment factor: 25.0
  Original sum: 4,731,122
  After adjustment: 3,861,655
Calculating affected population at 20m resolution...
Computing zonal statistics for 3 zones...

================================================================================
RESULTS BY ZONE
================================================================================
 zone_id  affected_population  flooded_pixels  total_pixels  flooded_area_m2  flooded_area_km2
       0                  325          130838  1.667928e+07     5.233527e+07         52.335274
       1                  512          306273  1.353145e+07     1.225095e+08        122.509475
       2                  772          174955  1.970349e+07     6.998233e+07         69.982328
Total affected population for 2024-10-25: 1,609

F.6.2 Flood Extent Visualizations

Code
# Visualize flood extent for October snapshot dates
map_flood_extent_snapshots(
    flood_data_computed=oct_flood_data_latest_computed,
    snapshot_dates=notebook_config['october']['snapshot_dates'],
    gdf_boundaries=gdf_aoi_copernicus,
    event_name=notebook_config['october']['name']
)

F.6.3 System Comparisons

FloodScan data not available for October event.

Code
# Hardcoded October 2024 CEMS reference data
# TODO: Replace x, y, z with actual CEMS values for October
oct_other_systems_data = {
    'CEMS': {
        'AOI 1': 8300,
        'AOI 2': 6400,  # TODO: Add October CEMS value for AOI 2
        'AOI 3': 26000   # TODO: Add October CEMS value for AOI 3
    }
}

# Create comparison plot with CEMS and GFM only (no FloodScan)
create_comparison_barplot(
    gfm_results=oct_latest_img_zonal_date,
    other_systems_data=oct_other_systems_data,
    event_name=notebook_config['october']['name'],
    systems=['CEMS', 'GFM (STAC)']  # Only CEMS and GFM
)

================================================================================
AFFECTED POPULATION COMPARISON - October 2024 Floods
================================================================================
System   CEMS  GFM (STAC)
AOI                      
AOI 1    8300         325
AOI 2    6400         511
AOI 3   26000         772


================================================================================
SYSTEM COMPARISON RATIOS (relative to GFM)
================================================================================

AOI 1:
  GFM (baseline): 325
  CEMS: 8,300 (25.54x GFM)

AOI 2:
  GFM (baseline): 511
  CEMS: 6,400 (12.52x GFM)

AOI 3:
  GFM (baseline): 772
  CEMS: 26,000 (33.68x GFM)

F.7 Appendix

F.7.1 Google Earth Engine GHSL stuff