3  Toward an Open-Source FloodScan?

FloodScan is a licensed product, but its pipeline is documented by its authors. The Data Users Guide states it plainly: “FloodScan processing produces an intermediate flooded fraction product at the passive microwave satellite data scales (~22-km). The algorithm downscales flooded fraction to make its flood depiction products (e.g., 90-m scale)” (FloodScan Data Users Guide v05R01, AER 2021, §1.2). The downscaling database is described in Galantowicz (AER, AGU 2018) as “built from topography, hydrology, and Global Surface Water Explorer data”; the approach dates to Galantowicz 2002.

Every ingredient in that recipe has a free counterpart: GFDS supplies the passive microwave signal (from the same instruments, posted at ~10 km rather than ~22 km), and JRC Global Surface Water supplies the water-history database. So this chapter takes an angle worth stating explicitly: can we build an open-source FloodScan? And with a finer-resolution microwave input and a 1998-to-present free archive, could it eventually be better?

The pipeline has two stages, because GFDS does not measure an amount of water. It measures how unusual a pixel looks compared to its own history, and you cannot draw “unusual” on a map at 90 m:

  1. Calibrate: convert the GFDS reading into an estimated share of each 10 km pixel that is under water (a flooded fraction).
  2. Allocate: split that share among the small (~83 m) cells inside each pixel, marking the most flood-prone cells as flooded first. (Why the odd 83 m: the Landsat water map comes at ~28 m; we aggregate it 3×3 to 0.00075°, chosen because 0.09° divides by it exactly, so every GFDS pixel holds a clean 120×120 block of fine cells. FloodScan’s “90 m” is 3 arcseconds; ours is 2.7, the price of nesting into the GFDS grid.)

Stage 2 depends on what statisticians call a prior: information that existed before today’s satellite reading. Ours is a historical water map built from every Landsat image since 1984: for each ~30 m cell, the share of observations in which it was wet. River channels score near 100%, floodplains somewhere in the tens, dry land zero. The division of labour is strict: today’s GFDS reading sets how much of each pixel is flooded, and the historical map decides which cells get marked.

Full technical write-up: docs/gfds-downscaling-poc.md; code in experiments/gfds_downscaling_poc/.

3.1 The methods, up front

Four different calibration methods appear in this chapter, and it matters which figure uses which. Declared once, here:

Label Calibration method Uses licensed data? Role in this chapter
R0 Per-pixel lookup table trained on FloodScan SFED (rank matching between each pixel’s GFDS and SFED histories) Yes, trained on SFED The reproduction test: how well can free input copy the licensed product? Also the ceiling for the independent methods. The downscaled-GFDS maps use the R0 fraction unless labelled otherwise; the allocation score table also includes the R1 chain.
R1 Physics: invert the wet/dry mixing equation with a literature emissivity contrast (K ≈ 0.35 at 36 GHz) and each pixel’s own dry-season signal level No The independence test’s lead candidate
R2 Per-pixel linear map anchored to the driest and wettest fractions Landsat ever observed (Global Surface Water) No Independence test, optical anchor
R3 Like R2 but the wet anchor is Sentinel-1 extent aggregated to the pixel for the October peak window No Independence test, radar anchor

Equally important, the things that are never calibration inputs, only comparison points:

  • FloodScan SFED: the benchmark throughout. For R1, R2, R3 it is fully external (they never see it), so agreement with it is evidence. For R0 it is the training target, so agreement is partly by construction; R0’s honest scores come from held-out days its lookup tables never saw.
  • GFM Sentinel-1 radar: a different product with a different overpass cadence. It appears once, late, to arbitrate one narrow question about the allocation stage. It calibrates nothing.
  • The history-only control: an allocation map built with no 2022 satellite input at all, used to measure how much of the fine-scale map comes from the historical prior rather than from any satellite.

The chapter now runs three tests in order: reproduction (R0 vs SFED), independence (R1-R3 vs SFED), and allocation (the 83 m maps).

3.2 Test 1: reproduction — the R0 lookup vs FloodScan

R0 is a per-pixel lookup table. For each 10 km pixel we line up its history of GFDS readings against its history of FloodScan readings: a middling GFDS day maps to that pixel’s typical FloodScan value, an extreme day to an extreme value. To keep ourselves honest, the table is built from half the days (alternating) and every score below comes from the other half, days the table never saw.

experiments/gfds_downscaling_poc/01_calibrate_fraction.py (method R0)
"""POC step 1: calibrate GFDS anomaly -> flooded fraction at 10 km.

Per-pixel monotone quantile mapping from the 4-day GFDS anomaly to FloodScan
SFED, fit and evaluated on interleaved day splits (odd/even matched days) so
every score below is out-of-sample. Baseline to beat: predicting each pixel's
training-mean SFED every day.

Outputs (outputs/gfds_downscaling_poc/):
- calibrated fraction stack (netcdf) for the full Nigeria window
- skill table printed + saved
Run: uv run python experiments/gfds_downscaling_poc/01_calibrate_fraction.py
"""
from pathlib import Path

import numpy as np
import pandas as pd
import xarray as xr

CACHE = Path("data/gdacs_gfds/nga2022")
OUT = Path("outputs/gfds_downscaling_poc")
OUT.mkdir(parents=True, exist_ok=True)
SENTINELS = (-32000, -2147483648)


def gfds_coords(shape):
    a, b, c, d, e, f = np.load(CACHE / "geotransform.npy")
    xs = c + (np.arange(shape[1]) + 0.5) * a
    ys = f + (np.arange(shape[0]) + 0.5) * e
    return xs, ys


def load_stack(prefix, scale, positive_only):
    files = sorted(CACHE.glob(f"{prefix}_2022*.npy"))
    arrs, dates = [], []
    for p in files:
        dates.append(pd.Timestamp(p.stem.split("_")[-1]))
        a = np.load(p).astype("float64")
        bad = np.isin(a, SENTINELS)
        if positive_only:
            bad |= a <= 0
        a[bad] = np.nan
        arrs.append(a / scale)
    xs, ys = gfds_coords(arrs[0].shape)
    return xr.DataArray(np.stack(arrs), dims=("time", "y", "x"),
                        coords={"time": dates, "y": ys, "x": xs})


signal = load_stack("signal", 1_000_000, positive_only=True)
avg = np.load(CACHE / "baseline_avg.npy").astype("float64")
sd = np.load(CACHE / "baseline_sd.npy").astype("float64")
for b in (avg, sd):
    b[np.isin(b, SENTINELS) | (b <= 0)] = np.nan
xs, ys = gfds_coords(avg.shape)
avg = xr.DataArray(avg / 1e6, dims=("y", "x"), coords={"y": ys, "x": xs})
sd = xr.DataArray(sd / 1e6, dims=("y", "x"), coords={"y": ys, "x": xs})
anom = ((avg - signal) / sd.clip(min=0.005)).transpose("time", "y", "x")
anom4 = anom.rolling(time=4, min_periods=1).mean()

co = np.load(CACHE / "sfed_coords.npz")
sfiles = sorted(CACHE.glob("sfed_2022*.npy"))
sfed = xr.DataArray(
    np.stack([np.load(p) for p in sfiles]), dims=("time", "y", "x"),
    coords={"time": [pd.Timestamp(p.stem.split("_")[-1]) for p in sfiles],
            "y": co["y"], "x": co["x"]},
).interp(x=signal.x, y=signal.y, method="linear")

sfed_i, anom_i = xr.align(sfed, anom4)
T = sfed_i.sizes["time"]
A = anom_i.values.reshape(T, -1)
W = sfed_i.values.reshape(T, -1)
print(f"matched days: {T} | pixels: {A.shape[1]}")

train = np.arange(T) % 2 == 0  # interleaved split
test = ~train


def quantile_map_fit_predict(a_tr, w_tr, a_te):
    """Empirical CDF match: rank of a in training anomalies -> same rank in
    training SFED. Monotone by construction."""
    ok = np.isfinite(a_tr) & np.isfinite(w_tr)
    if ok.sum() < 20:
        return np.full_like(a_te, np.nan)
    a_s = np.sort(a_tr[ok])
    w_s = np.sort(w_tr[ok])
    # rank of each test anomaly within training anomalies, in [0, 1]
    p = np.searchsorted(a_s, a_te, side="right") / len(a_s)
    p = np.clip(p, 0, 1)
    idx = p * (len(w_s) - 1)
    lo = np.floor(idx).astype(int)
    hi = np.ceil(idx).astype(int)
    frac = idx - lo
    out = w_s[lo] * (1 - frac) + w_s[hi] * frac
    out[~np.isfinite(a_te)] = np.nan
    return out


pred = np.full_like(W, np.nan)
base = np.full_like(W, np.nan)
for j in range(A.shape[1]):
    pred[test, j] = quantile_map_fit_predict(A[train, j], W[train, j], A[test, j])
    ok_tr = np.isfinite(W[train, j])
    if ok_tr.sum() >= 20:
        base[test, j] = np.nanmean(W[train, j])

# also produce full-period calibrated stack (fit on train days, apply to all)
pred_all = np.full_like(W, np.nan)
for j in range(A.shape[1]):
    pred_all[:, j] = quantile_map_fit_predict(A[train, j], W[train, j], A[:, j])

ok = np.isfinite(pred) & np.isfinite(W)
active = np.nanmax(W, axis=0) > 0.05  # pixels that actually flooded a bit
ok_act = ok & active[None, :]

def rmse(p, w, m):
    return float(np.sqrt(np.nanmean((p[m] - w[m]) ** 2)))

res = {
    "test days": int(test.sum()),
    "RMSE quantile-map (active px)": rmse(pred, W, ok_act),
    "RMSE mean-baseline (active px)": rmse(base, W, ok_act & np.isfinite(base)),
    "bias quantile-map (active px)": float(np.nanmean(pred[ok_act] - W[ok_act])),
}
# basin-scale flooded area time series on test days (the headline number)
px_area_km2 = (0.09 * 111.32) ** 2  # rough, ignores cos(lat); fine at 4-14N for POC
area_gfds = np.nansum(np.where(ok, pred, 0), axis=1) * px_area_km2
area_sfed = np.nansum(np.where(ok, W, 0), axis=1) * px_area_km2
td = test & (area_sfed > 0)
res["area corr (test days)"] = float(np.corrcoef(area_gfds[td], area_sfed[td])[0, 1])
res["area ratio gfds/sfed at SFED peak"] = float(
    area_gfds[np.nanargmax(area_sfed)] / np.nanmax(area_sfed))

for k, v in res.items():
    print(f"{k}: {v:.4f}" if isinstance(v, float) else f"{k}: {v}")

frac = xr.DataArray(pred_all.reshape(sfed_i.shape), dims=("time", "y", "x"),
                    coords=sfed_i.coords, name="gfds_fraction")
frac.to_netcdf(OUT / "gfds_calibrated_fraction.nc")
sfed_i.to_netcdf(OUT / "sfed_on_gfds.nc")
pd.Series(res).to_csv(OUT / "calibration_skill.csv")
print("wrote", OUT / "gfds_calibrated_fraction.nc")
load POC results (fails loudly if the scripts haven’t been run)
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xarray as xr

POC = next((p for p in (Path("outputs/gfds_downscaling_poc"),
                        Path("../outputs/gfds_downscaling_poc")) if p.exists()), None)
if POC is None or not (POC / "downscale_maps.npz").exists():
    raise FileNotFoundError(
        "POC outputs not found. Run experiments/gfds_downscaling_poc/ scripts "
        "01-07 from the repo root first (inputs are cached or range-read)."
    )
skill_cal = pd.read_csv(POC / "calibration_skill.csv", index_col=0)
frac = xr.open_dataarray(POC / "gfds_calibrated_fraction.nc")  # R0 fraction
sfed = xr.open_dataarray(POC / "sfed_on_gfds.nc")
skill_cal
0
test days 78.000000
RMSE quantile-map (active px) 0.040106
RMSE mean-baseline (active px) 0.047759
bias quantile-map (active px) 0.001047
area corr (test days) 0.798418
area ratio gfds/sfed at SFED peak 0.840114

On the unseen days, R0 predicts FloodScan’s value with a typical error of 0.040 (in flooded-share units, over pixels that actually flooded), versus 0.048 for the lazy strategy of always guessing each pixel’s average. A modest margin, honestly earned. The more tangible payoff is that GFDS can now express something it never could before: how many square kilometres are under water.

Code
px_km2 = (0.09 * 111.32) ** 2
both = np.isfinite(frac.values) & np.isfinite(sfed.values)
a_gfds = np.where(both, frac.values, 0).sum(axis=(1, 2)) * px_km2
a_sfed = np.where(both, sfed.values, 0).sum(axis=(1, 2)) * px_km2
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(frac.time, a_gfds, color="tab:red", label="GFDS via R0 lookup (free input)")
ax.plot(sfed.time, a_sfed, color="tab:blue", label="FloodScan SFED (licensed)")
ax.set_ylabel("flooded area (km²)")
ax.legend()
ax.set_title("Nigeria, Jun–Nov 2022")
plt.show()

Flooded area per day over Nigeria. Red: fraction from the R0 lookup (free input, SFED-trained). Blue: licensed FloodScan SFED (here and in every comparison, resampled onto the GFDS 0.09° grid). At FloodScan’s peak, the R0 estimate reaches 84% of its area.
Code
peak = slice("2022-10-06", "2022-10-13")
wg = frac.sel(time=peak).mean("time")
ws = sfed.sel(time=peak).mean("time")
fig, axes = plt.subplots(1, 2, figsize=(13, 5.5), sharex=True, sharey=True)
wg.plot(ax=axes[0], vmin=0, vmax=0.5, cmap="Blues", add_colorbar=False)
axes[0].set_title("GFDS fraction via R0 lookup (free input)")
im = ws.plot(ax=axes[1], vmin=0, vmax=0.5, cmap="Blues", add_colorbar=False)
axes[1].set_title("FloodScan SFED (licensed)")
fig.colorbar(im, ax=axes, shrink=0.8, label="flooded fraction")
plt.show()

g, s = wg.values.ravel(), ws.values.ravel()
ok = np.isfinite(g) & np.isfinite(s)
wet = ok & ((g > 0.02) | (s > 0.02))
print(f"pixel correlation, all valid pixels: {np.corrcoef(g[ok], s[ok])[0,1]:.2f}")
print(f"pixel correlation, pixels either product calls wet (>2%): "
      f"{np.corrcoef(g[wet], s[wet])[0,1]:.2f} (n={int(wet.sum())})")
print(f"mean fraction over wet pixels: GFDS-R0 {g[wet].mean():.3f} "
      f"| FloodScan {s[wet].mean():.3f}")

CALIBRATION STAGE ONLY, 10 km grid, no downscaling. Mean flooded fraction 6-13 Oct 2022. Left: the R0 (SFED-trained) fraction from the free GFDS input. Right: licensed FloodScan SFED, resampled from its native 0.083° grid onto the GFDS 0.09° grid so the two fields compare cell by cell. Because R0 is trained on SFED (alternating days from this same season), close agreement here is partly by construction; the held-out-day scores above are the fair skill test.
pixel correlation, all valid pixels: 0.97
pixel correlation, pixels either product calls wet (>2%): 0.96 (n=1699)
mean fraction over wet pixels: GFDS-R0 0.114 | FloodScan 0.110

A note on why the left panel looks as crisp as the right one, when the raw GFDS anomaly in Chapter 2 looked visibly coarser. We measured it, and the coarser look is mostly an optical effect, not a resolution difference: the two fields have identical fine-scale smoothness on the shared grid (one-cell spatial autocorrelation 0.74 for both season-max fields). What differs is how much of each map is lit up. Sixty-one percent of the GFDS anomaly map sits above a quarter of its color scale, against 7% for SFED, because the anomaly carries low-level wet-season signal everywhere while FloodScan’s MDFF threshold blanks it. A mostly blank map with thin features reads as sharp; a mostly colored map reads as blobby. The R0 conversion inherits SFED’s per-pixel value ranges, which zero that background out, so the converted map takes on SFED’s sparse look. One real, secondary difference survives the measurement: SFED’s features stay correlated further (0.58 vs 0.45 at two cells), reflecting the organized corridors its hydrological database imposes.

3.3 Test 2: independence — can we calibrate without FloodScan?

R0 proves the free input can copy the licensed product, but a copy is not an independent product. AER did not have a reference product when they built FloodScan; they got flooded fraction from brightness temperature with physics: a partly flooded pixel’s signal is a mix of a wet and a dry component, and the mixing equation can be inverted. R1, R2, and R3 are three ways to do that without touching FloodScan, which makes SFED a fully fair external benchmark for them.

experiments/gfds_downscaling_poc/06_independent_fractions.py (methods R1, R2, R3)
"""POC step 6: three FloodScan-independent routes from GFDS signal to flooded
fraction, scored against SFED as a fair external benchmark.

Routes (none uses FloodScan for calibration):
  R1 physics    f = f_perm + (s_dry - s) / (s_dry * K), K = 1 - eps_w/eps_land
                (literature emissivity contrast at 36 GHz H-pol, K ~ 0.35)
  R2 gsw-anchor per-pixel linear map pinned by two free optical anchors:
                dry signal quantile <-> GSW permanent fraction, and
                wettest signal <-> GSW historical-water envelope fraction
  R3 gfm-anchor like R2 but the wet anchor is Sentinel-1 extent aggregated to
                the cell for the Oct peak window (sparse cross-sensor anchor)
Ceiling:
  R0 sfed-trained per-pixel quantile map from script 01 (trained ON SFED)

Domain: confluence AOI coarse cells (where the GSW prior exists).
Run: uv run python experiments/gfds_downscaling_poc/06_independent_fractions.py
"""
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xarray as xr

CACHE = Path("data/gdacs_gfds/nga2022")
OUT = Path("outputs/gfds_downscaling_poc")
SENTINELS = (-32000, -2147483648)
K = 0.35  # 1 - eps_water/eps_land at 36 GHz H-pol (literature ~0.30-0.40)


def gfds_coords(shape):
    a, b, c, d, e, f = np.load(CACHE / "geotransform.npy")
    return (c + (np.arange(shape[1]) + 0.5) * a,
            f + (np.arange(shape[0]) + 0.5) * e)


def load_stack(prefix, scale, positive_only):
    files = sorted(CACHE.glob(f"{prefix}_2022*.npy"))
    arrs, dates = [], []
    for fp in files:
        dates.append(pd.Timestamp(fp.stem.split("_")[-1]))
        a = np.load(fp).astype("float64")
        bad = np.isin(a, SENTINELS)
        if positive_only:
            bad |= a <= 0
        a[bad] = np.nan
        arrs.append(a / scale)
    xs, ys = gfds_coords(arrs[0].shape)
    return xr.DataArray(np.stack(arrs), dims=("time", "y", "x"),
                        coords={"time": dates, "y": ys, "x": xs})


signal = load_stack("signal", 1_000_000, positive_only=True)
sig4 = signal.rolling(time=4, min_periods=1).mean()
sfed = xr.open_dataarray(OUT / "sfed_on_gfds.nc")
r0 = xr.open_dataarray(OUT / "gfds_calibrated_fraction.nc")
prior = np.load(OUT / "gsw_prior_aoi.npz")
occ, px, py = prior["occ"], prior["x"], prior["y"]
maps = np.load(OUT / "downscale_maps.npz")
gfm_wet, dom = maps["gfm_wet"], maps["dom"]

# ---- coarse cells inside the prior extent + their GSW/GFM aggregates ------
cells = []
for iy, yc in enumerate(signal.y.values):
    for ix, xc in enumerate(signal.x.values):
        if (px.min() + 0.045 < xc < px.max() - 0.045
                and py.min() + 0.045 < yc < py.max() - 0.045):
            i0 = np.searchsorted(-py, -(yc + 0.045))
            i1 = np.searchsorted(-py, -(yc - 0.045))
            j0 = np.searchsorted(px, xc - 0.045)
            j1 = np.searchsorted(px, xc + 0.045)
            o = occ[i0:i1, j0:j1]
            g = gfm_wet[i0:i1, j0:j1]
            d = dom[i0:i1, j0:j1]
            cells.append({
                "iy": iy, "ix": ix,
                "f_perm": float((o > 80).mean()),
                "f_env": float((o >= 5).mean()),   # historical water envelope
                "f_gfm": float(g[d].mean()) if d.sum() > 100 else np.nan,
            })
C = pd.DataFrame(cells)
iy, ix = C.iy.values, C.ix.values
S = sig4.values[:, iy, ix]          # (time, cell) 4-day signal
W = sfed.values[:, iy, ix]          # benchmark
R0 = r0.values[:, iy, ix]

s_dry = np.nanquantile(S, 0.85, axis=0)
s_wet = np.nanquantile(S, 0.02, axis=0)
peak = (sig4.time >= pd.Timestamp("2022-10-06")) & (sig4.time <= pd.Timestamp("2022-10-13"))
s_peak = np.nanmin(S[peak.values], axis=0)

f_perm, f_env, f_gfm = C.f_perm.values, C.f_env.values, C.f_gfm.values

def clip01(a):
    return np.clip(a, 0.0, 1.0)

R1 = clip01(f_perm[None, :] + (s_dry - S) / (s_dry * K))
den2 = np.where(s_dry - s_wet > 1e-4, s_dry - s_wet, np.nan)
R2 = clip01(f_perm[None, :] + (s_dry - S) / den2 * (f_env - f_perm)[None, :])
den3 = np.where(s_dry - s_peak > 1e-4, s_dry - s_peak, np.nan)
R3 = clip01(f_perm[None, :] + (s_dry - S) / den3 * (f_gfm - f_perm)[None, :])

# ---- score against SFED ----------------------------------------------------
wet_cells = np.nanmax(W, axis=0) > 0.05

def score(P, name):
    ok = np.isfinite(P) & np.isfinite(W)
    okw = ok & wet_cells[None, :]
    r = np.corrcoef(P[okw], W[okw])[0, 1]
    rmse = float(np.sqrt(np.nanmean((P[okw] - W[okw]) ** 2)))
    bias = float(np.nanmean(P[okw] - W[okw]))
    a_p = np.nansum(np.where(ok, P, 0), axis=1)
    a_w = np.nansum(np.where(ok, W, 0), axis=1)
    pk = np.nanargmax(a_w)
    return {"route": name, "r (wet cells)": round(float(r), 3),
            "RMSE": round(rmse, 4), "bias": round(bias, 4),
            "area ratio at SFED peak": round(float(a_p[pk] / a_w[pk]), 2)}

res = pd.DataFrame([
    score(R1, "R1 physics (literature K)"),
    score(R2, "R2 GSW-anchored"),
    score(R3, "R3 GFM-anchored"),
    score(R0, "R0 SFED-trained (ceiling, not independent)"),
]).set_index("route")
print(res.to_string())
res.to_csv(OUT / "independent_fraction_skill.csv")

# ---- area time series figure ----------------------------------------------
fig, ax = plt.subplots(figsize=(11, 4.5))
t = sig4.time.values
pxa = (0.09 * 111.32) ** 2
ok_all = np.isfinite(W)
for P, lab, col in [(R1, "R1 physics", "tab:green"),
                    (R2, "R2 GSW-anchored", "tab:red"),
                    (R3, "R3 GFM-anchored", "tab:orange"),
                    (R0, "R0 SFED-trained (ceiling)", "tab:grey")]:
    okp = np.isfinite(P) & ok_all
    ax.plot(t, np.nansum(np.where(okp, P, 0), axis=1) * pxa, label=lab,
            color=col, lw=1.2)
ax.plot(t, np.nansum(np.where(ok_all, W, 0), axis=1) * pxa,
        label="FloodScan SFED (benchmark)", color="tab:blue", lw=2)
ax.set_ylabel("flooded area (km²), AOI")
ax.legend(fontsize=8)
ax.set_title("Confluence AOI: independent fraction routes vs SFED, 2022")
fig.tight_layout()
fig.savefig(OUT / "independent_fraction_areas.png", dpi=140, bbox_inches="tight")
np.savez_compressed(OUT / "independent_fractions.npz",
                    R1=R1, R2=R2, R3=R3, R0=R0, W=W, time=t.astype("datetime64[D]"),
                    iy=iy, ix=ix, wet_cells=wet_cells)
print("wrote independent_fraction_areas.png + independent_fractions.npz")
Code
z2 = np.load(POC / "independent_fractions.npz")
R1, R2, R3, R0, W = (z2[k] for k in ("R1", "R2", "R3", "R0", "W"))
tt = pd.to_datetime(z2["time"])
pxa = (0.09 * 111.32) ** 2
okW = np.isfinite(W)
fig, ax = plt.subplots(figsize=(11, 4.5))
for P, lab, col in [(R1, "R1 physics", "tab:green"),
                    (R2, "R2 optical anchor", "tab:red"),
                    (R3, "R3 radar anchor", "tab:orange"),
                    (R0, "R0 SFED-trained (ceiling)", "tab:grey")]:
    okp = np.isfinite(P) & okW
    ax.plot(tt, np.nansum(np.where(okp, P, 0), axis=1) * pxa, label=lab,
            color=col, lw=1.2)
ax.plot(tt, np.nansum(np.where(okW, W, 0), axis=1) * pxa,
        label="FloodScan SFED (benchmark)", color="tab:blue", lw=2)
ax.set_ylabel("flooded area (km²)")
ax.legend(fontsize=8)
plt.show()

wet_cells = z2["wet_cells"]
def rscore(P):
    m = np.isfinite(P) & np.isfinite(W) & wet_cells[None, :]
    a_p = np.nansum(np.where(np.isfinite(P) & okW, P, 0), axis=1)
    a_w = np.nansum(np.where(okW, W, 0), axis=1)
    pk = int(np.nanargmax(a_w))
    return {"r vs SFED (wet cells)": round(float(np.corrcoef(P[m], W[m])[0, 1]), 2),
            "area ratio at peak": round(float(a_p[pk] / a_w[pk]), 2)}
r1m = np.where(R1 >= 0.10, R1, 0.0)
pd.DataFrame({"R1 physics": rscore(R1),
              "R1 physics + minimum-detectable threshold": rscore(r1m),
              "R2 optical anchor": rscore(R2),
              "R3 radar anchor": rscore(R3),
              "R0 SFED-trained (ceiling)": rscore(R0)}).T

Flooded area over the confluence AOI: the three FloodScan-free calibrations (R1-R3), the SFED-trained ceiling (R0), and FloodScan itself. R1 (green) reproduces the flood wave and its peak with no FloodScan input; both anchor routes flatline because their anchors never saw a 2022-sized flood.
r vs SFED (wet cells) area ratio at peak
R1 physics 0.84 0.99
R1 physics + minimum-detectable threshold 0.83 0.86
R2 optical anchor 0.42 0.19
R3 radar anchor 0.74 0.24
R0 SFED-trained (ceiling) 0.94 0.83

The physics route wins, and not narrowly: r = 0.84 against SFED and 99% of its peak area, from a literature constant and the pixel’s own history. The anchor routes fail for a structural reason worth remembering: 2022 flooded beyond anything in their anchors’ experience, so amplitudes calibrated to Landsat history or three radar snapshots cap out far too low. You cannot anchor an extreme to a record that never contained one.

R1’s one visible flaw is a dry-season floor: a few hundred km² of low-level “flood” in June-August where SFED reports almost none, from soil moisture and seasonal wetness leaking into the signal. FloodScan’s Users Guide documents the identical problem and its fix, a “minimum detectable flooded fraction (MDFF) threshold” applied to filter “low level flooded fraction noise” (v05R01, §1.2). Applying the same idea (zero out fractions below 0.10) cuts our June floor to SFED’s level while keeping r = 0.83 and 86% of peak area. Independently rediscovering the need for their threshold is about as strong a confirmation of the shared physics as one event can give.

3.4 Test 3: allocation — the 83 m maps

The remaining stage is spatial. The historical water map is the JRC Global Surface Water dataset (free, ~30 m, fetched here by reading just the needed window over HTTP). This is the same dataset AER names as an ingredient of FloodScan’s downscaling database; their version also folds in topography and hydrology layers, so our single-ingredient version is a floor on what the technique can do, not a replica. Inside each 10 km pixel we sort the small cells from most to least water-prone, exclude cells that are water year-round (lakes, the river channel itself), and mark cells as flooded from the top of the list until the pixel’s estimated share is reached.

One method note before the maps: the allocation experiments below were run with the R0 fraction as the GFDS-side input (they predate the R1 results; re-running them on R1 is listed in the next steps). The scripts:

experiments/gfds_downscaling_poc/02_build_prior.py
"""POC step 2: build the downscaling prior for the confluence AOI.

Reads JRC Global Surface Water occurrence (1984-2021, ~28 m) for the AOI via
HTTP range requests, aggregates 3x3 to 0.00075 deg (~83 m) so that each GFDS
0.09 deg cell contains exactly 120x120 prior cells, and saves:
- occ (mean occurrence %, float32)
- permanent water mask (occurrence > 80%)
Run: uv run python experiments/gfds_downscaling_poc/02_build_prior.py
"""
from pathlib import Path

import numpy as np
import rasterio
from rasterio.windows import from_bounds

AOI = (6.0, 6.3, 7.8, 8.3)  # lon_min, lat_min, lon_max, lat_max
OUT = Path("outputs/gfds_downscaling_poc")
OUT.mkdir(parents=True, exist_ok=True)
URL = ("/vsicurl/https://storage.googleapis.com/global-surface-water/"
       "downloads2021/occurrence/occurrence_0E_10Nv1_4_2021.tif")

with rasterio.open(URL) as src:
    win = from_bounds(*AOI, src.transform)
    occ = src.read(1, window=win)
    tr = src.window_transform(win)
print("native window:", occ.shape)

# GSW encoding: 0-100 = occurrence %, 255 = nodata over land/ocean
occf = occ.astype("float32")
occf[occ == 255] = 0.0  # never-observed-as-water -> prior 0

ny, nx = occf.shape
ny3, nx3 = ny // 3 * 3, nx // 3 * 3
agg = occf[:ny3, :nx3].reshape(ny3 // 3, 3, nx3 // 3, 3).mean(axis=(1, 3))
xs = tr.c + (np.arange(nx3 // 3) * 3 + 1.5) * tr.a
ys = tr.f + (np.arange(ny3 // 3) * 3 + 1.5) * tr.e

perm = agg > 80.0
print(f"prior grid: {agg.shape} at 0.00075 deg | permanent-water px: {perm.sum():,} "
      f"({100 * perm.mean():.2f}%) | occ>0 px: {(agg > 0).sum():,}")
np.savez_compressed(OUT / "gsw_prior_aoi.npz", occ=agg.astype("float32"),
                    perm=perm, x=xs, y=ys)
print("wrote", OUT / "gsw_prior_aoi.npz")
experiments/gfds_downscaling_poc/04_downscale_validate.py
"""POC step 4: downscale the calibrated GFDS fraction to ~83 m and validate
against GFM Sentinel-1 extent.

Downscaling = conservative flood-fill: inside each 0.09 deg cell, rank the
~83 m cells by GSW occurrence (desc) then distance-to-historical-water (asc),
and mark the top fraction*n cells wet. Permanent water (occ>80) is excluded
from both prediction and scoring.

Maps compared against GFM (max extent, same Oct window):
- downscaled GFDS (the POC)
- downscaled SFED (upper bound: same fill, licensed input)
- prior-only control (same total area as GFDS, allocated by prior alone,
  ignoring WHERE GFDS put the water at 10 km)
Run: uv run python experiments/gfds_downscaling_poc/04_downscale_validate.py
"""
import json
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xarray as xr
from scipy.ndimage import distance_transform_edt

OUT = Path("outputs/gfds_downscaling_poc")

frac = xr.open_dataarray(OUT / "gfds_calibrated_fraction.nc")
sfed = xr.open_dataarray(OUT / "sfed_on_gfds.nc")
prior = np.load(OUT / "gsw_prior_aoi.npz")
occ, perm = prior["occ"], prior["perm"]
px, py = prior["x"], prior["y"]  # fine coords (x asc, y desc)
gfm = xr.open_dataset(OUT / "gfm_extent_peak.nc")["gfm_flood"]
dates = pd.to_datetime(json.loads((OUT / "gfm_extent_dates.json").read_text()))

# window value = max over the GFM observation dates (same definition per product)
have = [d for d in dates if d in frac.time]
if len(have) < 3:
    raise RuntimeError(f"only {len(have)} GFM dates overlap the GFDS stack: {have}")
w_gfds = frac.sel(time=have).max("time")
w_sfed = sfed.sel(time=have).max("time")
print(f"window: {len(have)} shared dates {have[0].date()}..{have[-1].date()}")

# secondary ranking key: distance to any historical water
dist = distance_transform_edt(occ <= 0)
order_key = np.lexsort(( dist.ravel(), -occ.ravel() ))  # occ desc, then dist asc

# coarse cells fully inside the prior extent
xmin, xmax = px.min(), px.max()
ymin, ymax = py.min(), py.max()
cells = [(iy, ix) for iy, y in enumerate(w_gfds.y.values)
         for ix, x in enumerate(w_gfds.x.values)
         if xmin + 0.045 < x < xmax - 0.045 and ymin + 0.045 < y < ymax - 0.045]

def downscale(wmap):
    out = np.zeros(occ.shape, dtype=bool)
    for iy, ix in cells:
        xc = float(wmap.x[ix]); yc = float(wmap.y[iy])
        w = float(wmap.values[iy, ix])
        if not np.isfinite(w) or w <= 0:
            continue
        i0 = np.searchsorted(-py, -(yc + 0.045))   # py descending
        i1 = np.searchsorted(-py, -(yc - 0.045))
        j0 = np.searchsorted(px, xc - 0.045)
        j1 = np.searchsorted(px, xc + 0.045)
        sub_occ = occ[i0:i1, j0:j1]
        sub_perm = perm[i0:i1, j0:j1]
        sub_dist = dist[i0:i1, j0:j1]
        valid = ~sub_perm
        n_wet = int(round(min(w, 1.0) * valid.sum()))
        if n_wet == 0:
            continue
        flat_order = np.lexsort((sub_dist.ravel(), -sub_occ.ravel()))
        flat_order = flat_order[valid.ravel()[flat_order]]
        sel = flat_order[:n_wet]
        block = np.zeros(sub_occ.size, dtype=bool)
        block[sel] = True
        out[i0:i1, j0:j1] |= block.reshape(sub_occ.shape)
    return out

ds_gfds = downscale(w_gfds)
ds_sfed = downscale(w_sfed)

# prior-only control: same total wet count, allocated AOI-wide by prior rank
n_total = int(ds_gfds.sum())
ctrl = np.zeros(occ.size, dtype=bool)
elig = order_key[~perm.ravel()[order_key]]
ctrl[elig[:n_total]] = True
ctrl = ctrl.reshape(occ.shape)

# GFM (20 m, 0/1/nan) binned onto the 83 m prior grid
gy = gfm.y.values; gx = gfm.x.values
vals = gfm.values.astype("float32")
finite = np.isfinite(vals)
yy, xx = np.meshgrid(gy, gx, indexing="ij")
ye = np.concatenate([py + 0.000375, [py[-1] - 0.000375]])[::-1]  # asc edges
xe = np.concatenate([px - 0.000375, [px[-1] + 0.000375]])
wet_sum, _, _ = np.histogram2d(yy[finite], xx[finite], bins=(ye, xe),
                               weights=(vals[finite] > 0).astype("float32"))
cnt, _, _ = np.histogram2d(yy[finite], xx[finite], bins=(ye, xe))
gfm_frac = np.full(occ.shape, np.nan, dtype="float32")
with np.errstate(invalid="ignore"):
    gfm_frac[:] = (wet_sum / np.where(cnt > 0, cnt, np.nan))[::-1]  # back to y desc
gfm_wet = gfm_frac >= 0.5
gfm_valid = np.isfinite(gfm_frac) & (cnt[::-1] >= 8)  # >=8 of ~17 20m px seen

# scoring domain: valid GFM, non-permanent, inside scored coarse cells
scored = np.zeros(occ.shape, dtype=bool)
for iy, ix in cells:
    if np.isfinite(w_gfds.values[iy, ix]):
        yc = float(w_gfds.y[iy]); xc = float(w_gfds.x[ix])
        i0 = np.searchsorted(-py, -(yc + 0.045)); i1 = np.searchsorted(-py, -(yc - 0.045))
        j0 = np.searchsorted(px, xc - 0.045); j1 = np.searchsorted(px, xc + 0.045)
        scored[i0:i1, j0:j1] = True
dom = gfm_valid & ~perm & scored
print(f"scoring domain: {dom.sum():,} cells | GFM wet in domain: {int((gfm_wet & dom).sum()):,}")

def skill(pred):
    h = int((pred & gfm_wet & dom).sum())
    f = int((pred & ~gfm_wet & dom).sum())
    m = int((~pred & gfm_wet & dom).sum())
    pod = h / (h + m) if h + m else np.nan
    far = f / (h + f) if h + f else np.nan
    csi = h / (h + m + f) if h + m + f else np.nan
    return {"POD": round(pod, 3), "FAR": round(far, 3), "CSI": round(csi, 3),
            "wet_km2": round(int((pred & dom).sum()) * 0.0835 ** 2, 0)}

res = pd.DataFrame({
    "downscaled GFDS (POC)": skill(ds_gfds),
    "downscaled SFED (upper bound)": skill(ds_sfed),
    "prior-only control": skill(ctrl),
}).T
print(res.to_string())
res.to_csv(OUT / "downscale_skill.csv")

# compact artifact for the book chapter: the four maps + domain + coords
np.savez_compressed(
    OUT / "downscale_maps.npz",
    gfm_wet=gfm_wet, ds_gfds=ds_gfds, ds_sfed=ds_sfed, ctrl=ctrl,
    dom=dom, x=px, y=py,
    window=np.array([str(d.date()) for d in have]),
)

fig, axes = plt.subplots(2, 2, figsize=(14, 12), sharex=True, sharey=True)
ext = [px.min(), px.max(), py.min(), py.max()]
show = lambda ax, m, t, cmap: (ax.imshow(np.where(dom, m, np.nan), extent=ext,
                               cmap=cmap, vmin=0, vmax=1, interpolation="none"),
                               ax.set_title(t, fontsize=11))
show(axes[0, 0], gfm_wet.astype(float), "GFM Sentinel-1 max extent (truth here)", "Blues")
show(axes[0, 1], ds_gfds.astype(float), "downscaled GFDS (free, this POC)", "Reds")
show(axes[1, 0], ds_sfed.astype(float), "downscaled SFED (licensed input)", "Purples")
show(axes[1, 1], ctrl.astype(float), "prior-only control (no 10 km info)", "Greys")
for ax in axes.ravel():
    ax.set_aspect("equal")
fig.suptitle(f"Niger-Benue confluence, {have[0].date()} to {have[-1].date()}, ~83 m",
             fontsize=13)
fig.tight_layout()
fig.savefig(OUT / "downscale_maps.png", dpi=130, bbox_inches="tight")
print("wrote", OUT / "downscale_maps.png")

3.4.1 Head to head: downscaled GFDS vs downscaled FloodScan

Test 1 compared the two products at the calibration stage, on the 10 km grid. This figure is the next stage of the same story: the same two 10 km fractions, now pushed through the identical allocation, compared as 83 m maps over the confluence for the October peak.

Code
from matplotlib.colors import ListedColormap
from matplotlib.patches import Patch

z = np.load(POC / "downscale_maps.npz")
gfm_wet, ds_gfds, ds_sfed, ctrl, dom = (z[k] for k in
                                        ("gfm_wet", "ds_gfds", "ds_sfed", "ctrl", "dom"))
ext = [z["x"].min(), z["x"].max(), z["y"].min(), z["y"].max()]

cat = np.zeros(ds_gfds.shape, dtype=float)
cat[ds_sfed & ds_gfds] = 1
cat[ds_sfed & ~ds_gfds] = 2
cat[~ds_sfed & ds_gfds] = 3
cmap = ListedColormap(["#f7f7f7", "#2c7fb8", "#fdae61", "#d7191c"])
fig, ax = plt.subplots(figsize=(8, 8))
ax.imshow(np.where(dom, cat, np.nan), extent=ext, cmap=cmap,
          vmin=-0.5, vmax=3.5, interpolation="none")
ax.set_aspect("equal")
ax.legend(handles=[
    Patch(color="#2c7fb8", label="both flag flooding"),
    Patch(color="#fdae61", label="FloodScan-based only"),
    Patch(color="#d7191c", label="GFDS-based only")],
    loc="lower left", fontsize=9, framealpha=0.9)
plt.show()

both_n = int((ds_gfds & ds_sfed & dom).sum())
either_n = int(((ds_gfds | ds_sfed) & dom).sum())
a_g = int((ds_gfds & dom).sum()) * 0.0835**2
a_s = int((ds_sfed & dom).sum()) * 0.0835**2
print(f"flooded area: GFDS-based {a_g:,.0f} km2 | FloodScan-based {a_s:,.0f} km2 "
      f"(ratio {a_g/a_s:.2f})")
print(f"overlap: {both_n/either_n:.0%} of all cells flagged by either map "
      "are flagged by both")

CALIBRATION + ALLOCATION, ~83 m. The same two 10 km fractions from Test 1 after downscaling, Niger-Benue confluence, 6-13 Oct 2022. Blue = both flag flooding, orange = only the FloodScan-based map, red = only the GFDS-based map.
flooded area: GFDS-based 3,169 km2 | FloodScan-based 3,484 km2 (ratio 0.91)
overlap: 84% of all cells flagged by either map are flagged by both

The two maps flag nearly the same total area and mostly the same cells; the disagreements sit at pixel edges where the two 10 km inputs put slightly different amounts into neighbouring pixels. Through an identical recipe, the free input substitutes for the licensed one with little visible cost. Whether either map is right is a separate question, which needs a reference from outside the microwave family.

3.4.2 An outside check: radar, and a control with no satellite input

For that we borrow this project’s GFM data: Sentinel-1 radar flood extent for the same dates. Radar is a different product with a different overpass cadence, so disagreement with the daily microwave products is expected and does not crown a winner; its job here is only to arbitrate one narrow question. Alongside the two downscaled maps we score a third: the history-only control defined in the methods section, same total flooded area, no 2022 satellite input. If the satellite-based maps cannot beat the control against the radar, the fine detail is coming from the historical map, not from either satellite product.

Code
def agreement(pred):
    cat = np.zeros(pred.shape, dtype=float)          # 0: dry in both
    cat[gfm_wet & pred] = 1                           # both wet
    cat[gfm_wet & ~pred] = 2                          # radar only
    cat[~gfm_wet & pred] = 3                          # map only
    return np.where(dom, cat, np.nan)

cmap = ListedColormap(["#f7f7f7", "#2c7fb8", "#fdae61", "#d7191c"])
fig, axes = plt.subplots(1, 2, figsize=(13, 7), sharex=True, sharey=True)
for ax, pred, title in [(axes[0], ds_gfds, "downscaled GFDS (R0) vs radar"),
                        (axes[1], ctrl, "history-only control vs radar")]:
    ax.imshow(agreement(pred), extent=ext, cmap=cmap, vmin=-0.5, vmax=3.5,
              interpolation="none")
    ax.set_title(title, fontsize=11)
    ax.set_aspect("equal")
axes[0].legend(handles=[
    Patch(color="#2c7fb8", label="both flooded (hit)"),
    Patch(color="#fdae61", label="radar flooded, this map dry (miss)"),
    Patch(color="#d7191c", label="this map flooded, radar dry (false alarm)")],
    loc="lower left", fontsize=8, framealpha=0.9)
plt.tight_layout()
plt.show()

Niger-Benue confluence, 6-13 Oct 2022, ~83 m. Each panel compares one map against the radar: blue = both flag flooding, orange = radar flags flooding the map missed, red = the map flags flooding the radar did not see. Left: downscaled GFDS (R0 fraction). Right: the history-only control. GFDS converts orange to blue across the wide flooding south of the confluence, at the cost of red false alarms in rectangular blocks at pixel edges.
scores at 83 m
def score(pred):
    h = int((pred & gfm_wet & dom).sum())
    f = int((pred & ~gfm_wet & dom).sum())
    m = int((~pred & gfm_wet & dom).sum())
    return {"POD/Recall": round(h / (h + m), 3),
            "FAR (1−Precision)": round(f / (h + f), 3),
            "CSI/IoU": round(h / (h + m + f), 3)}

zr1 = np.load(POC / "r1_downscaled.npz")  # from script 07
pd.DataFrame({"downscaled GFDS (R0, SFED-trained)": score(ds_gfds),
              "downscaled GFDS (R1 physics, independent)": score(zr1["ds_r1"]),
              "downscaled GFDS (R1 + MDFF 0.10)": score(zr1["ds_r1m"]),
              "downscaled SFED (licensed)": score(ds_sfed),
              "history-only control": score(ctrl)}).T
POD/Recall FAR (1−Precision) CSI/IoU
downscaled GFDS (R0, SFED-trained) 0.630 0.798 0.180
downscaled GFDS (R1 physics, independent) 0.591 0.874 0.116
downscaled GFDS (R1 + MDFF 0.10) 0.587 0.847 0.138
downscaled SFED (licensed) 0.691 0.798 0.185
history-only control 0.601 0.791 0.184

POD is the share of radar-flooded cells the map caught, FAR the share of the map’s flooded cells the radar contradicts, CSI the overall overlap.

Two results at full 83 m detail are uncomfortable and worth stating plainly. First, the history-only control ties the maps that used satellite data. Second, the fully independent chain (R1 physics fraction through the same allocation) currently scores below all of them: its peak-window amplitude runs high, painting roughly half again more area than the R0 version, and the radar punishes the over-spread as false alarms. Independence is demonstrated at 10 km; at 83 m it still needs amplitude tuning. The figure shows a visible difference, yet cell by cell the gains on the floodplain are cancelled by the block-edge false alarms. Also notable: the free GFDS input scores within a whisker of the licensed FloodScan input. One fairness note about the reference: radar under-detects water beneath vegetation, and we compare three radar snapshots against continuous microwave coverage, so part of every map’s “red” reflects the reference’s blind spots rather than the map’s errors.

3.4.3 Where it really flooded: beyond the historical envelope

A fair objection to everything above: cells with Landsat water history are easy, the fill paints them first, so scores there flatter every map. The telling stratum is the cells outside the historical envelope, where water appeared in places the 1984-2021 record never saw it. Detection there cannot come from water history.

stratify the radar comparison by the historical water envelope
prior = np.load(POC / "gsw_prior_aoi.npz")
occ = prior["occ"]
inside = dom & (occ > 0)
outside = dom & (occ == 0)
wet_in = int((gfm_wet & inside).sum())
wet_out = int((gfm_wet & outside).sum())
print(f"radar-flooded cells with water history: {wet_in:,} | without: {wet_out:,} "
      f"({wet_out/(wet_in+wet_out):.0%} of the flood was outside the envelope)")

rows = {}
for name, pred in [("R0 (SFED-trained)", ds_gfds),
                   ("R1 physics", zr1["ds_r1"]),
                   ("SFED (licensed)", ds_sfed),
                   ("history-only control", ctrl)]:
    hi = int((pred & gfm_wet & inside).sum()); mi = int((~pred & gfm_wet & inside).sum())
    ho = int((pred & gfm_wet & outside).sum()); mo = int((~pred & gfm_wet & outside).sum())
    fo = int((pred & ~gfm_wet & outside).sum())
    rows[name] = {"POD/Recall inside": round(hi/(hi+mi), 2),
                  "POD/Recall outside": round(ho/(ho+mo), 2),
                  "FAR outside": round(fo/(ho+fo), 2),
                  "CSI/IoU outside": round(ho/(ho+mo+fo), 3)}
pd.DataFrame(rows).T
radar-flooded cells with water history: 21,353 | without: 124,370 (85% of the flood was outside the envelope)
POD/Recall inside POD/Recall outside FAR outside CSI/IoU outside
R0 (SFED-trained) 0.99 0.57 0.79 0.184
R1 physics 0.96 0.53 0.88 0.107
SFED (licensed) 1.00 0.64 0.79 0.190
history-only control 1.00 0.53 0.77 0.190

Three things fall out. First, the headline: 85% of this flood happened in cells with no Landsat water history at all. A flood mask built from historical water alone would have missed most of the event; that single number is the case for satellite monitoring of extremes. Second, inside the envelope everyone scores near-perfect detection, confirming that stratum tells you nothing. Third, outside the envelope the satellite-informed maps do detect more (licensed SFED reaches 0.64 recall, R0 0.57, against 0.53 for the control) but no more precisely, so the overlap scores still tie. One design honesty note explains why the control stays competitive even here: its tie-break ranks cells by distance to historical water, and since rivers flood outward, proximity geometry alone predicts much of even the novel flooding. Strictly, the control is “history plus geometry”, and at 83 m that combination remains hard for a 10 km satellite signal to beat.

3.4.4 Zooming out: where the satellite earns its keep

If the fine detail is mostly the historical map, the satellite has to prove itself at coarser zoom. We blur all the maps to a series of coarser grids and check how well each tracks the radar:

agreement with radar (correlation) at increasing blur
def block_reduce(a, k, m):
    ny, nx = a.shape
    ny2, nx2 = ny // k * k, nx // k * k
    aa = np.where(m, a, np.nan)[:ny2, :nx2].reshape(ny2 // k, k, nx2 // k, k)
    with np.errstate(invalid="ignore"):
        return np.nanmean(np.nanmean(aa, axis=3), axis=1)

rows = []
for k, label in [(1, "83 m"), (6, "0.5 km"), (12, "1 km"), (36, "3 km"), (72, "6 km")]:
    g = block_reduce(gfm_wet.astype(float), k, dom)
    vs = block_reduce(dom.astype(float), k, np.ones_like(dom, bool))
    mm = np.isfinite(g) & (vs > 0.5)
    row = {"scale": label}
    for name, pred in [("GFDS (R0)", ds_gfds), ("SFED", ds_sfed),
                       ("history-only control", ctrl)]:
        p = block_reduce(pred.astype(float), k, dom)
        ok = mm & np.isfinite(p)
        row[name] = round(float(np.corrcoef(p[ok], g[ok])[0, 1]), 3)
    rows.append(row)
pd.DataFrame(rows).set_index("scale")
GFDS (R0) SFED history-only control
scale
83 m 0.329 0.346 0.328
0.5 km 0.472 0.489 0.461
1 km 0.545 0.558 0.522
3 km 0.680 0.684 0.631
6 km 0.788 0.791 0.722

Now the picture is clean. At 83 m all three maps agree with the radar equally (and equally poorly). At coarser evaluation grids, the two maps built from 2022 satellite data pull ahead of the control by a clear margin from 3 km out. And at every level, free GFDS sits within a hair of licensed FloodScan.

3.5 What this settles

  1. The useful product is the calibrated 10 km fraction, not the 83 m map. It gives GFDS physical units, comparable across pixels and summable into areas and exposure estimates. That is what admin-level monitoring consumes, and it costs nothing.
  2. The 83 m map is an allocation of the coarse estimate, not an observation. It is a reasonable way to decide which communities inside a pixel to count when overlaying population, but it must never be read as observed flood extent.
  3. Both open-source questions survive their first test. Reproduction: the R0 lookup matches the licensed product at 10 km, and through the identical allocation the two 83 m maps overlap on 84% of flagged cells with a 0.91 area ratio. Independence: the R1 physics calibration needs no licensed data at all and reaches r = 0.84 and 99% of peak area against the licensed product.

The path from “free copy” to “maybe better” now has concrete work items: refine R1 (per-pixel emissivity contrast, a seasonal dry reference instead of one quantile, a tuned minimum-detectable threshold — the allocation test above shows its amplitude runs hot at the peak, so this tuning is what stands between “independent at 10 km” and “independent end to end”), train on the 28-year free archive rather than one season, add terrain to the historical map, and replicate on a second basin. The free input’s finer working resolution (~10 km vs ~22 km) is the standing structural advantage.