Appendix E — Global Daily Anomalies

This document demonstrates a proof of concept for querying and visualizing global flood anomalies at scale. We connect to the GFM (Global Flood Monitoring) STAC catalog to identify tiles where flood anomalies have been detected, then visualize the spatial distribution and intensity of these flood events.

The workflow includes: 1. Connecting to the STAC API to query flood anomaly data 2. Filtering for tiles with detected anomalies over recent time periods 3. Creating visualizations showing flood intensity across Africa and globally

Code
import pystac_client
import stackstac
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap, BoundaryNorm, LogNorm
import matplotlib.ticker as ticker
import numpy as np
import geopandas as gpd
from ds_flood_gfm.geo_utils import load_adm0_lowres
from shapely.geometry import shape
from datetime import datetime, timedelta
import pandas as pd
from matplotlib.colors import LogNorm

# Configure matplotlib for better display
%matplotlib inline
plt.rcParams['figure.dpi'] = 100

E.1 Africa - Last 7 days

We start by analyzing flood anomalies across Africa for the past week. This regional analysis helps us understand the spatial and temporal patterns of flood events across the continent.

Code
world = load_adm0_lowres()
gdf_africa = world[world.continent == "Africa"]
gdf_africa_bound = gdf_africa.total_bounds

E.1.1 Connect & Query STAC

We connect to the EODC STAC API and query for GFM tiles covering Africa from September 16-22, 2025. The query specifically filters for tiles where flood anomalies have been detected using the anomaly_detected property.

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

search = client.search(
    collections=["GFM"],
    bbox=gdf_africa_bound,  # Huge geographic extent
    datetime="2025-09-16/2025-09-22",
    query={"anomaly_detected": {"eq": True}},  # Only scenes with advisories
)


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

The following code processes the STAC query results to extract flood advisory information. For each tile with detected anomalies, we collect the geometry, timestamp, and flood intensity metrics. We then create daily summaries showing the most severe flooding detected in each tile per day.

Code
# Extract advisory geometries and metadata efficiently
advisory_tiles = []
for item in item_collection:
    advisory_tiles.append(
        {
            "geometry": shape(item.geometry),
            "datetime": item.datetime,
            "anomaly_detected": item.properties.get("anomaly_detected", False),
            "flooded_pixels": item.properties.get("flooded_pixels", 0),
            "tile_id": item.properties.get("Equi7Tile"),
            "id": item.id,
        }
    )

# Convert to GeoDataFrame
advisory_gdf = gpd.GeoDataFrame(advisory_tiles)
advisory_gdf["date"] = advisory_gdf["datetime"].dt.date
max_per_day = advisory_gdf.loc[
    advisory_gdf.groupby(["date", "tile_id"])["flooded_pixels"].idxmax()
]


# Optional: Also add a general filter for degenerate geometries
def is_reasonable_geometry(geom):
    bounds = geom.bounds
    width = bounds[2] - bounds[0]
    height = bounds[3] - bounds[1]
    return width < 50 and height < 50 and width > 0.01 and height > 0.01


max_per_day = max_per_day[max_per_day.geometry.apply(is_reasonable_geometry)]
# Filter for the last 3 days and create individual plots


# Get the last 3 days (Sep 21, 20, 19)
target_dates = ["2025-09-22", "2025-09-21", "2025-09-20"]

# Create subplots for the 3 days
fig, axes = plt.subplots(1, 3, figsize=(18, 6))

# Calculate global min/max for consistent color scale
all_data = max_per_day[
    max_per_day["datetime"].dt.date.isin(
        [pd.to_datetime(date).date() for date in target_dates]
    )
]

if len(all_data) > 0:
    # Use log scale for color mapping

    # Get min/max values, ensuring we don't have zeros for log scale
    min_val = max(all_data["flooded_pixels"].min(), 1)  # Avoid log(0)
    max_val = all_data["flooded_pixels"].max()

    # Create log normalizer
    log_norm = LogNorm(vmin=min_val, vmax=max_val)
else:
    log_norm = None
    min_val, max_val = 0, 1

for i, date_str in enumerate(target_dates):
    # Filter data for specific date
    date_data = max_per_day[
        max_per_day["datetime"].dt.date == pd.to_datetime(date_str).date()
    ]

    # Plot on corresponding subplot
    ax = axes[i]

    # Plot Africa first as background
    gdf_africa.plot(ax=ax, color="lightgray", edgecolor="gray", alpha=0.5)

    if len(date_data) > 0:
        # Plot with log-scaled colors but original values in legend
        im = date_data.plot(
            ax=ax,
            column="flooded_pixels",
            cmap="Reds",
            edgecolor="darkred",
            alpha=0.8,
            linewidth=0.5,
            norm=log_norm,
            legend=True,
            legend_kwds={
                "label": "Flooded Pixels",
                "shrink": 0.8,
                "format": ticker.ScalarFormatter(),  # Show actual numbers, not log values
            },
        )
        ax.set_title(f"Advisory Tiles - {date_str}\n({len(date_data)} tiles)")
    else:
        ax.set_title(f"Advisory Tiles - {date_str}\n(No data)")

    ax.set_xlabel("Longitude")
    ax.set_ylabel("Latitude")
    ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

E.2 Global - Last Day

Next, we expand our analysis to global scale, focusing on the most recent day (September 22, 2025) to show flood anomalies detected worldwide. This demonstrates the system’s capability to monitor flood events at a planetary scale.

Code
global_search = client.search(
    collections=["GFM"],
    bbox=np.array([-180, -90, 180, 90]),  # Huge geographic extent
    datetime="2025-09-22/2025-09-22",
    query={"anomaly_detected": {"eq": True}},  # Only scenes with advisories
)


item_collection = global_search.item_collection()
# stack = stackstac.stack(item_collection, epsg=4326)

The global visualization uses the same processing approach as the Africa analysis, but shows flood anomalies across all continents. The map uses a logarithmic color scale to effectively display the wide range of flood intensities detected globally.

Code
# Extract advisory geometries and metadata efficiently
advisory_tiles = []
for item in item_collection:
    advisory_tiles.append(
        {
            "geometry": shape(item.geometry),
            "datetime": item.datetime,
            "anomaly_detected": item.properties.get("anomaly_detected", False),
            "flooded_pixels": item.properties.get("flooded_pixels", 0),
            "tile_id": item.properties.get("Equi7Tile"),
            "id": item.id,
        }
    )

# Convert to GeoDataFrame
advisory_gdf = gpd.GeoDataFrame(advisory_tiles)
advisory_gdf["date"] = advisory_gdf["datetime"].dt.date
max_per_day = advisory_gdf.loc[
    advisory_gdf.groupby(["date", "tile_id"])["flooded_pixels"].idxmax()
]


# Create single plot for the one date
fig, ax = plt.subplots(1, 1, figsize=(12, 8))

# Calculate min/max for color scale
all_data = max_per_day

if len(all_data) > 0:

    # Get min/max values, ensuring we don't have zeros for log scale
    min_val = max(all_data["flooded_pixels"].min(), 1)  # Avoid log(0)
    max_val = all_data["flooded_pixels"].max()

    # Create log normalizer
    log_norm = LogNorm(vmin=min_val, vmax=max_val)
else:
    log_norm = None
    min_val, max_val = 0, 1

# Plot world as background
world.plot(ax=ax, color="lightgray", edgecolor="gray", alpha=0.5)

if len(max_per_day) > 0:
    # Plot with log-scaled colors but original values in legend
    im = max_per_day.plot(
        ax=ax,
        column="flooded_pixels",
        cmap="Reds",
        edgecolor="darkred",
        alpha=0.8,
        linewidth=0.5,
        norm=log_norm,
        legend=True,
        legend_kwds={
            "label": "Flooded Pixels",
            "shrink": 0.8,
            "format": ticker.ScalarFormatter(),  # Show actual numbers, not log values
        },
    )
    ax.set_title(f"Advisory Tiles\n({len(max_per_day)} tiles)")
else:
    ax.set_title("Advisory Tiles\n(No data)")

ax.set_xlabel("Longitude")
ax.set_ylabel("Latitude")
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()