Skip to content

Instantly share code, notes, and snippets.

@bennyistanto
Last active January 9, 2026 07:59
Show Gist options
  • Select an option

  • Save bennyistanto/934f3ab9b92fdc2b1aaa6436c480d80e to your computer and use it in GitHub Desktop.

Select an option

Save bennyistanto/934f3ab9b92fdc2b1aaa6436c480d80e to your computer and use it in GitHub Desktop.
Sentinel-1 Flood Mapping using Google Earth Engine - Configurable, Probability-Based Workflow (with optional SLC coherence)

Sentinel-1 Flood Mapping

Google Earth Engine - Configurable, Probability-Based Workflow (with optional SLC coherence)


This Gist contains a Google Earth Engine (GEE) JavaScript script for rapid flood mapping using Sentinel-1 SAR (GRD), designed for research, simulation, and operational prototyping where you want many tunable parameters rather than a fixed “black box” result.

GEE link: https://code.earthengine.google.com/f4ebbc6907ca02f7ea5ecf0d88c2b5b7

1. Background and motivation

World Bank’s GOST has used SAR for multiple applications, including flood mapping (see the broader tooling repo: https://github.com/worldbank/GOST_SAR/).

During hydrometeorological disasters, the United Nations Satellite Centre (UNOSAT) often supports humanitarian response by releasing free rapid satellite-derived flood/impact products, frequently leveraging Sentinel-1 SAR. These products are extremely useful for rapid situational awareness, but—as with any Earth Observation workflow—results may over-detect or miss flooded areas depending on acquisition timing, land cover, wind/rough water, terrain artefacts, and thresholding assumptions. Field verification remains essential.

This script is intended to fill the “tuning gap”: it implements a transparent SAR flood-mapping workflow with open configuration so analysts can adjust AOI definition, compositing, thresholds, weighting, masking, and export settings for different events and landscapes.


2. What the script does (high level)

Core mapping outputs

  • Produces a continuous flood probability raster (0–1) by combining multiple SAR-based evidence layers.
  • Produces a binary flood extent mask after plausibility masking and post-processing.

Evidence layers (GRD)

The workflow uses Sentinel-1 GRD composites and combines:

  1. ΔVV (post − pre) change detection with Otsu thresholding
  2. VV post-event darkness (Otsu thresholding)
  3. VH/VV ratio (where VH − VV in dB is large, typical for open water)

Each evidence term is converted into a soft probability and combined via configurable weights.

Multi-orbit robustness

  • Processes ascending and descending orbits separately.
  • Fuses per-orbit probabilities using a max operator to reduce false negatives (configurable).

Physics-based plausibility masks

To reduce artefacts and implausible detections, the script applies:

  • Slope constraint (removes steep terrain where radar shadow/layover can mimic dark water)
  • HAND (Height Above Nearest Drainage) constraint (keeps low terrain near drainage network)
  • Permanent water exclusion using JRC Global Surface Water occurrence

Meteorology cross-check layers

Adds optional diagnostic layers and time series to support plausibility checking:

  • IMERG precipitation accumulation and time series (rate + cumulative)
  • ECMWF IFS precipitation rate, vorticity (850 hPa), and 10m wind speed

Optional: Sentinel-1 SLC coherence fusion (advanced)

If you compute coherence externally (SNAP / HyP3 / ISCE2 / Gamma) and upload to GEE, the script can fuse:

  • GRD probability (dominant)
  • Coherence-based probability (supporting evidence)

3. Example event context: Cyclone / Tropical Depression Senyar (late Nov 2025)

In late November 2025, Cyclone Senyar / TD-34W reportedly produced severe flooding and landslides across parts of Sumatra, including Aceh and North Sumatra. UNOSAT and other actors commonly publish rapid EO-derived extents in such events https://experience.arcgis.com/experience/21c23ad9ae04421ea818a34471143fcd/page/UNOSAT?views=Home, but analyst tuning and field validation remain crucial.

This script includes an example event window in CONFIG.DATES (you should adjust to your case).

Case example:

This example illustrates how the workflow can be configured and interpreted for an inland, mountainous AOI, where SAR artefacts (shadow/layover), steep slopes, and AOI geometry can influence thresholds and mapped extents.

  • Blangkejeren (Gayo Lues, Aceh, Indonesia)

    Blangkejeren town center is approximately at 3.995461°N, 97.341347°E.

    Figure 1 - GEE run (annotated)

    Screenshot 2026-01-09 114552

    Figure 1. Screenshot from Google Earth Engine Code Editor showing the fused flood-probability output and/or flood mask for the Blangkejeren AOI. The red circle highlights a reference landmark (the bridge across the Aih Bobo River) that is reported to have collapsed, as shown in Figure 2.

    Figure 2 - Field situation (reference photo)

    Figure 2 - Aerial view of flood damage in Desa Rigeb, Kecamatan Blangkejeren, Kabupaten Gayo Lues, Aceh (2 Dec 2025). Source: ANTARA Foto.

    Figure 2. Photo credit: ANTARA FOTO/Taufik Hidayat/rwa. Source page: https://www.antaranews.com/foto/5283929/banjir-bandang-putus-akses-utama-gayo-lues.

  • Jembatan Kembar Silaing (Padang Panjang, Sumatera Barat, Indonesia)

    The bridge location is at -0.475659, 100.367263

    Figure 3 - GEE run (annotated)

    Screenshot 2025-12-03 153849

    Figure 3. Screenshot from Google Earth Engine Code Editor showing the fused flood-probability output and/or flood mask for the Padang Panjang AOI. The red circle indicates the location of the damaged Jembatan Kembar Silaing, as shown in Figure 4.

    Figure 4 - Field situation (reference photo)

    Figure 4 - Flood/debris impact around Padang Panjang (Nov 2025). Source: Wikimedia Commons.

    Figure 4. Photo credit: Rahmatdenas (27 Nov 2025), licensed CC BY 4.0 via Wikimedia Commons.
    Source page: https://commons.wikimedia.org/wiki/File:Galodo_Padang_Panjang_-_Nov_2025.jpg (License: https://creativecommons.org/licenses/by/4.0/).


4. Datasets used (default)

  • Sentinel-1 GRD: COPERNICUS/S1_GRD
  • Copernicus DEM: COPERNICUS/DEM/GLO30
  • MERIT Hydro HAND (via a public GEE asset collection used in-script)
  • JRC Global Surface Water: JRC/GSW1_4/GlobalSurfaceWater
  • NASA GPM IMERG v07 (Daily): NASA/GPM_L3/IMERG_V07
  • ECMWF IFS (forecast archive): ECMWF/IFS/0P25_HOURLY

Note: dataset IDs are configurable in the script.


5. Outputs

Map layers (in the GEE Map)

Primary:

  • Flood Probability (Fused) (0–1)
  • Flood Extent (binary) (0/1)

Diagnostics (toggle on as needed):

  • Per-orbit layers (pre/post VV, ΔVV, flood probability)
  • Plausibility mask layers: Permanent water, HAND, slope
  • Meteorology layers: IMERG accumulation, IFS diagnostics

Console outputs

  • Image counts per orbit / window (useful sanity checks)
  • Threshold diagnostics (Otsu)
  • Flooded area summary (hectares)

Optional exports (GeoTIFF to Google Drive)

When enabled, the script queues exports for:

  1. FloodProb_Fused_YYYY-MM-DD.tif (Float32, 0–1)
  2. FloodMask_YYYY-MM-DD.tif (Byte, 0/1)
  3. IMERGaccum_YYYY-MM-DD.tif (Float32, mm)
  4. IFSdiagnostics_YYYY-MM-DD.tif (Float32, multiband)

Important: exports appear in the Tasks tab and require manual RUN.


6. Quick start

  1. Open Google Earth Engine Code Editor: https://code.earthengine.google.com/
  2. Paste the script (or load from your Gist).
  3. Edit only the CONFIG block first.

Step 1 - Set dates

In CONFIG.DATES, set:

  • eventStart (YYYY-MM-DD)
  • eventEnd (YYYY-MM-DD, inclusive)
  • baselineDays (recommended 30–60 for many events)

Example (already in script as a template):

DATES: {
  eventStart: '2025-11-22',
  eventEnd:   '2025-12-07',
  baselineDays: 60
},

Step 2 - Set AOI (5 modes)

CONFIG.AOI.mode supports:

  • GAUL : admin lookup using GAUL 2024 L2
  • ASSET : your uploaded FeatureCollection
  • DRAWN : draw geometry in the Code Editor (Imports panel) named geometry
  • PASTE : paste var geometry = ee.Geometry(...)
  • AUTO : tries ASSET → PASTE → GAUL

If using GAUL, set:

GAUL: {
  adminDataset: 'projects/sat-io/open-datasets/FAO/GAUL/GAUL_2024_L2',
  gaul0_name: 'Indonesia',
  gaul1_name: 'Aceh',
  gaul2_name: 'Aceh Utara'
}

Step 3 - Run and inspect

  • Run the script.

  • Inspect the primary layers:

    • Flood Probability (Fused)
    • Flood Extent (binary)
  • Toggle on diagnostics to understand behaviour:

    • Per-orbit ΔVV, post VV, and per-orbit probability
    • Permanent water, HAND, slope

7. Configuration guide (what you will most often tune)

CONFIG.SCORE (probability fusion)

Controls weights for evidence layers:

  • change detection (ΔVV)
  • absolute darkness (VV post)
  • VH/VV ratio

Typical tuning pattern:

  • Increase w_change if your baseline is stable and flood is clearly “new water”
  • Increase w_abs if post-event water is consistently dark and baseline is noisy
  • Increase w_ratio when VH/VV separation is strong (often in open water / wetlands)

CONFIG.MASKS (plausibility thresholds)

  • maxSlopeDeg (default ~5°)
  • maxHANDm (default ~40 m)
  • permanent water occurrence threshold (e.g., 85%)

Typical tuning pattern:

  • Coastal/pluvial flooding: HAND may be too strict → increase maxHANDm or disable HAND masking for testing
  • Mountainous AOI: keep slope masking on; consider stricter slope threshold

CONFIG.POST (final binary mask)

  • floodProbThreshold (default around 0.35)
  • minConnectedPixels (speckle removal)
  • optional morphology (smoothing)

Typical tuning pattern:

  • Too conservative (missing flood): lower floodProbThreshold
  • Too much noise: increase minConnectedPixels and/or raise floodProbThreshold

CONFIG.HIST (Otsu stability / performance)

If you hit memory limits or see unstable thresholds in coastal AOIs:

  • enable sampling-based histograms
  • constrain histogram masks (DEM footprint + exclude permanent water)
  • increase tileScale in reductions

8. Optional: SLC coherence fusion (advanced)

GEE does not provide Sentinel-1 SLC coherence by default. If you want to add coherence evidence:

  1. Compute coherence externally (SNAP / ASF HyP3 / ISCE2 / Gamma)

  2. Export coherence GeoTIFF (0–1)

  3. Upload GeoTIFF to GEE Assets

  4. Update:

    • CONFIG.COH.eventCohAssetId
    • (optional) CONFIG.COH.preCohAssetId
  5. Set CONFIG.COH.enabled = true

Fusion logic (default):

  • Final = w_grd * P_grd + w_coh * P_coh (GRD dominant, coherence supporting)

Tuning:

  • lowCohThresh (lower = more sensitive, higher = stricter)
  • cohDropThresh (how much coherence must drop from pre to event)

When to skip coherence:

  • No SLC processing capacity
  • Dense vegetation / agriculture where coherence is naturally low
  • Very rapid events where suitable SLC pairs are not available

9. Exporting results

  1. Set:
CONFIG.EXPORT.enabled = true
  1. Re-run the script.
  2. Open Tasks (top-right in Code Editor) and click RUN for each queued export.
  3. Outputs appear in Google Drive under CONFIG.EXPORT.folder (default: GEE_FLOOD).

10. Recommended validation workflow (practical)

  1. Compare the flood extent with:

    • Local situation reports / field photos / drone reconnaissance (if available)
    • High-resolution optical imagery (if cloud permits) for sanity checks
    • UNOSAT or other rapid-mapping products as a first-order reference
  2. Use meteorology layers:

    • SAR flood + high IMERG accumulation supports plausibility
    • SAR flood + low rainfall suggests checking for artefacts, tides, dam releases, or misclassification
  3. Tune parameters iteratively:

    • Adjust AOI, baselineDays, SCORE weights, MASKS, and POST threshold

11. Limitations (read this)

  • SAR flood mapping is challenging in dense urban areas, wind-roughened water, and under vegetation canopy.
  • Radar shadow/layover and smooth manmade surfaces can mimic “dark water” if not constrained.
  • This script targets “floodwater likely” screening, not definitive inundation truth.
  • AOIs including ocean/coastline can shift Otsu thresholds unless histogram sampling is constrained.

Field validation is mandatory for operational decision-making.


12. Troubleshooting

  • “No flood detected”
    • Check precipitation cross-check layers; lower floodProbThreshold; confirm S1 coverage in event window.
  • “Too much flood / too many false positives”
    • Tighten permanent water threshold; increase slope/HAND constraints; raise floodProbThreshold.
  • “Memory exceeded / timeout”
    • Reduce AOI; enable sampling histograms; increase export scale; turn off extra layers.

13. References / useful links


14. Contact

Benny Istanto (Climate Geographer, GOST/DEC Data Group, The World Bank)
Email: bistanto@worldbank.org


15. License

Public domain.

/* ---------------------------------------------------------------------------------------
* Flood mapping (Sentinel-1) — Flexible AOI + Flood probability + OPTIONAL SLC coherence
* ---------------------------------------------------------------------------------------
* GEE code: https://code.earthengine.google.com/f4ebbc6907ca02f7ea5ecf0d88c2b5b7
*
* PURPOSE
* -------
* Flood mapping workflow in Google Earth Engine to:
* - Detect likely floodwater using Sentinel-1 GRD (VV/VH) change + absolute backscatter evidence.
* - Support flexible AOI selection (GAUL admin boundaries, user asset, drawn geometry, or pasted geometry).
* - Stabilize results across AOI shapes (e.g., bounding box vs admin polygon) via optional histogram sampling masks.
* - Apply physics-aware plausibility masks (permanent water, slope, HAND) and simple post-processing.
* - Provide meteorology cross-check layers (IMERG precipitation and ECMWF IFS diagnostics).
* - Optionally fuse external Sentinel-1 SLC coherence evidence (computed outside GEE and uploaded as assets).
*
* DATASETS
* --------
* - COPERNICUS/S1_GRD : Sentinel-1 GRD backscatter (VV/VH) and incidence angle.
* - COPERNICUS/DEM/GLO30 : Copernicus DEM GLO-30 (for slope footprint/mask and slope derivatives).
* - MERIT/Hydro/v1_0_1 : HAND (hnd) — Height Above Nearest Drainage.
* - JRC/GSW1_4/GlobalSurfaceWater : Surface water occurrence (permanent water masking).
* - NASA/GPM_L3/IMERG_V07 : IMERG precipitation (cross-check).
* - ECMWF/NRT_FORECAST/IFS/OPER : ECMWF IFS NRT fields (cross-check).
* - (Optional user assets) : External SLC coherence GeoTIFF(s) uploaded to GEE Assets.
*
* UNITS & CONVENTIONS
* -------------------
* - Sentinel-1 VV/VH: dB (log scale).
* - ΔVV: dB (post - pre). Floodwater generally decreases VV (more negative ΔVV).
* - VH/VV ratio: computed as (VH - VV) in dB (water-like scattering tends to be more negative).
* - Probability outputs: 0–1 (higher = more flood-like).
* - Permanent water: GSW occurrence in % (0–100).
* - Slope: degrees.
* - HAND: meters.
* - IMERG accumulation: mm (converted from mm/hr using timestep hours).
* - IFS precipitation rate: mm/hr (converted from kg m^-2 s^-1 via *3600).
*
* METHOD (KEY IDEAS)
* ------------------
* 1) Multi-orbit ensemble:
* - Compute flood probability separately for ASCENDING and DESCENDING orbits,
* then take pixelwise MAX to reduce geometry-driven false negatives.
* 2) Evidence ensemble (not a single threshold):
* - Otsu threshold on ΔVV (change-driven water detection).
* - Otsu threshold on VV_post (absolute low-backscatter water detection).
* - Soft VH/VV ratio constraint (water-like scattering signature).
* - Combine these into a continuous 0–1 probability surface.
* 3) Physics-aware plausibility masks:
* - Mask permanent water using JRC GSW occurrence.
* - Restrict to plausible flooded terrain using slope (Copernicus DEM) + HAND (MERIT Hydro).
* 4) AOI-shape robustness (optional):
* - Optional histogram sampling mask for Otsu threshold estimation (DEM land footprint + exclude permanent water),
* to reduce differences between DRAWN bbox and GAUL polygons in coastal areas.
* 5) OPTIONAL coherence fusion:
* - Fuse GRD probability with external Sentinel-1 SLC coherence evidence (low coherence and/or coherence drop).
*
* USAGE
* -----
* 1) Set CONFIG: AOI mode, dates (event + baseline), and S1 reducers (pre median, post min recommended).
* 2) Run script: inspect per-orbit layers (Pre/Post VV, ΔVV, FloodProb) to validate behavior.
* 3) Inspect plausibility layers (Slope, HAND, Permanent water) to understand removals.
* 4) Adjust thresholds/weights (CONFIG.SCORE, CONFIG.MASKS, CONFIG.POST) based on AOI and event.
* 5) (Optional) enable EXPORT to Drive for probability, mask, and met cross-check rasters.
*
* LIMITATIONS / NOTES
* -------------------
* - SAR flood mapping is challenging in dense urban areas, rough/windy water, and under vegetation canopy.
* - Radar shadow/layover and smooth manmade surfaces can mimic “dark water” if not constrained.
* - This script targets “floodwater likely” mapping (robust screening), not definitive inundation truth.
* - AOIs that include ocean/coastline can shift Otsu thresholds unless histogram sampling is constrained.
*
* PERFORMANCE NOTES
* -----------------
* - Large AOIs + histogram reductions can trigger "Earth Engine memory capacity exceeded".
* - If you hit memory limits:
* - Prefer sampling-based histograms (instead of full reduceRegion histograms).
* - Keep histogram masks simple (DEM footprint + permanent water exclusion).
* - Increase tileScale in reductions; avoid unnecessary Map layers at full res.
* - Consider reducing AOI extent or using coarser histogram scale for Otsu estimation.
*
* CONTACT
* -------
* Benny Istanto (Climate Geographer, GOST/DEC Data Group, The World Bank)
* Email: bistanto@worldbank.org
*
* LICENSE
* -------
* Public domain.
*
* CHANGELOG
* ---------
* 2025-12-03T10:15+07:00 Baseline integrated script: flexible AOI modes (GAUL/ASSET/DRAWN/PASTE/AUTO),
* multi-orbit GRD flood probability, plausibility masks, and met cross-check layers.
* 2025-12-03T12:05+07:00 DEM/slope robustness: Copernicus DEM-only slope via Terrain.products, safe masking
* to avoid slope=0 artifacts in Pixel Inspector.
* 2025-12-03T13:10+07:00 AOI-shape consistency: optional histogram sampling mask (DEM footprint + exclude
* permanent water) for more stable Otsu thresholds across GAUL vs bbox AOIs.
* 2025-12-03T13:55+07:00 Performance note: histogram masking / extra reductions may trigger memory limits;
* prefer sampling or coarser histogram scale when needed.
*
* ------------------------------------------------------------------------------
*/
/** #############################################
* 0) CONFIG (EDIT ME)
* #############################################
*
* QUICK START CHECKLIST:
* ----------------------
* 1. Set DATES (eventStart, eventEnd) [Line 154]
* 2. Choose AOI mode (DRAWN/GAUL/ASSET/PASTE) [Line 184]
* 3. If GAUL: Update gaul0/1/2_name [Line 199]
* 4. Review SCORE weights (w_change/abs/ratio) [Line 307]
* 5. Review MASKS thresholds (slope/HAND) [Line 464]
* 6. Review POST processing threshold [Line 553]
* 7. (Optional) Enable EXPORT to save outputs [Line 692]
*
* For detailed documentation on each section,
* see inline comments below.
* #############################################
*/
var CONFIG = {
// ============================================================
// DATES: Event Window + Baseline Reference
// ------------------------------------------------------------
// PURPOSE:
// - eventStart/eventEnd: Define the suspected flood period (when water was present)
// - baselineDays: Length of pre-flood reference window for change detection
//
// STRATEGY:
// - Baseline window: [eventStart - baselineDays, eventStart)
// - Event window: [eventStart, eventEnd + 1 day) (filterDate uses end-exclusive)
// - Change detection: compare event composite vs baseline composite
//
// TUNING GUIDANCE:
// - eventStart/eventEnd: Set based on precipitation/news reports (±2-3 days uncertainty is OK)
// - baselineDays:
// * Too short (10-20 days): Baseline may include pre-flood rain → weak change signal
// * Too long (120+ days): Baseline may include seasonal changes → false positives
// * RECOMMENDED: 30-60 days for most events (balances stability vs seasonal drift)
// * Increase to 90-120 days if: sparse S1 coverage, need more acquisitions
// * Decrease to 14-30 days if: rapid seasonal change (monsoon onset, snowmelt)
//
// SENTINEL-1 REVISIT:
// - S1A+S1B combined: ~6-day revisit (Europe), ~12-day elsewhere
// - 60-day baseline typically gives: 5-10 acquisitions per orbit
// - Check Console prints for actual image counts per orbit
//
// EXAMPLE:
// Event: Nov 22-28, 2025 (post-storm flooding)
// Baseline: Sep 23 - Nov 22, 2025 (60 days before event)
DATES: {
eventStart: '2025-11-22', // First day of suspected flooding (YYYY-MM-DD)
eventEnd: '2025-12-07', // Last day of suspected flooding (inclusive)
baselineDays: 60 // Pre-flood reference window length (days before eventStart)
}, // End of DATES
// ============================================================
// AOI: Area of Interest Selection (FLEXIBLE MODES)
// ------------------------------------------------------------
// PURPOSE:
// - Define the geographic extent for flood analysis
// - Support multiple input methods to fit different workflows
//
// AVAILABLE MODES:
// - 'GAUL' : Admin lookup using GAUL 2024 L2 (country/province/district names)
// - 'ASSET' : Load FeatureCollection from your GEE Assets
// - 'DRAWN' : Geometry drawn in Code Editor (Imports panel), variable named `geometry`
// - 'PASTE' : Geometry pasted as code below (var geometry = ee.Geometry...)
// - 'AUTO' : Smart selection: ASSET → PASTE → GAUL (checks availability in order)
//
// IMPORTANT NOTES:
// - PASTE mode uses a code variable named `geometry` (see Lines 719-724)
// - DRAWN mode also uses `geometry` variable (created automatically by Code Editor)
// - Both PASTE and DRAWN expect the same variable name; CONFIG.AOI.mode controls which is used
// - Replace geometry coordinates at Lines 382-387 if using mode='PASTE'
//
AOI: {
mode: 'DRAWN', // Choose 'GAUL' for admin boundaries, 'ASSET' for your uploaded FC,
// 'DRAWN' for Code Editor geometry, 'PASTE' for geometry code below, or
// 'AUTO' for automatic selection
// Fallback buffer when GAUL lookup fails or needs centroid approximation (meters)
fallbackBufferM: 40000,
// Debug output: prints GAUL matches + candidate lists to Console
// Set true to see available admin names when debugging GAUL lookups
debug: true,
// Maximum number of GAUL candidates to print (prevents Console overflow)
maxList: 50,
// (A) GAUL
// Check complete list of country, province and district from below link, and get GAUL 2024 L2 Metadata
// https://data.apps.fao.org/catalog/iso/60b23906-f21a-49ef-8424-f3645e70264e
GAUL: {
adminDataset: 'projects/sat-io/open-datasets/FAO/GAUL/GAUL_2024_L2',
gaul0_name: 'Indonesia',// change to your target country
gaul1_name: 'Aceh', // change to your target province/region
gaul2_name: 'Aceh Utara' // change to your target district/city
}, // CRITICAL: Update these three names to match your target area exactly
// Use CONFIG.AOI.debug=true to see available admin name candidates printed in Console
// (B) PASTE (geometry pasted as code)
PASTE: { bufferM: 0 },
// (C) DRAWN (geometry drawn in UI)
DRAWN: { bufferM: 0 },
// (D) ASSET
// If using mode='ASSET', paste your GEE Asset path here, inside '' (e.g., 'users/yourname/aoi_fc')
// The asset must be a FeatureCollection or single Feature
// bufferM=0 means use geometry as-is; increase to expand AOI boundary
ASSET: {
assetId: '', // e.g. 'users/yourname/my_aoi_fc'
bufferM: 0
}
}, // End of AOI
// ============================================================
// SENTINEL-1 CONFIGURATION: Acquisition & Processing Settings
// ------------------------------------------------------------
// PURPOSE:
// - Control which Sentinel-1 data is used and how it's preprocessed
// - Balance between detection sensitivity and false positive rate
//
// KEY DECISIONS:
// - instrumentMode='IW': Standard land imaging mode (consistent geometry/resolution)
// - polarizations=['VV','VH']: Dual-pol enables ratio constraint (reduces false positives)
// - ORBITS=['ASCENDING','DESCENDING']: Multi-orbit reduces geometry-driven misses
//
S1: {
collection: 'COPERNICUS/S1_GRD',
// Sentinel-1 IW is the standard land mode; ensures consistent incidence-angle range & resolution.
// If you change away from IW, you'll mix geometries and your thresholds/ratios become less stable.
instrumentMode: 'IW',
// Using both VV and VH gives you: VV strongest for open water darkening; VH helps discriminate veg/wet soils via ratio.
// If you drop VH, ratio constraint disappears (often increases false positives in smooth surfaces).
polarizations: ['VV', 'VH'],
// Using both passes reduces geometry-driven misses (shadow/layover orientation effects).
// Using only one orbit can increase false negatives depending on look direction relative to terrain/urban layout.
ORBITS: ['ASCENDING', 'DESCENDING'],
// ------------------------------------------------------------
// PREPROCESSING: CRITICAL CHOICES
// ------------------------------------------------------------
// These settings directly affect backscatter uniformity and detection stability.
//
// Simple gamma0-by-angle correction reduces incidence-angle brightness gradient.
// true => more comparable backscatter across swath; better Otsu stability on big AOIs.
// false => raw sigma0 in dB; thresholds can “track” the incidence-angle pattern (more artefacts).
// Note: not full terrain flattening; in steep terrain you’ll still see geometry effects.
applyGamma0AngleCorrection: true,
// Masks border noise / scalloping (very low VV) at scene edges.
// true => fewer salt-and-pepper false floods on edges.
// false => sometimes more false positives near borders.
clipEdges: true,
// VV dB cutoff for edge-mask. Pixels below are masked.
// More negative (e.g., -40) => looser mask, keeps more pixels but may keep border noise.
// Less negative (e.g., -30) => stricter mask, removes more edges; risk removing real dark water near edges.
edgeDbThreshold: -35,
// Pre-event composite reducer.
// 'median' => robust baseline (less sensitive to one weird wet day); good for change detection stability.
// 'mean' => smoother but sensitive to outliers.
// 'min' => dangerous for baseline (would make baseline already “water-like”, reducing deltaVV signal).
temporalReducerPre: 'median',
// Post-event composite reducer.
// 'min' => intentionally “peak water” selector: any acquisition with very low VV will dominate -> higher recall.
// 'median'/'mean' => closer to typical condition over window; can miss short-lived flood peaks.
// 'min' may also pick wind-roughened water artefacts / shadow pockets (false positives) if not masked well.
temporalReducerPost: 'min' // IMPORTANT for flood
}, // End of S1
// ============================================================
// FLOOD SCORING: EVIDENCE WEIGHTS + THRESHOLD SOFTNESS
// ------------------------------------------------------------
// PHILOSOPHY:
// - NOT a single hard threshold (brittle, noise-sensitive)
// - ENSEMBLE of three complementary evidence types (robust, interpretable)
// - SOFT transitions via sigmoid function (continuous probability 0-1)
//
// EVIDENCE TYPES:
// 1) Change (ΔVV): Detects newly darkened pixels (flood over normally bright land)
// 2) Absolute (VV_post): Detects intrinsically dark surfaces (open water)
// 3) Ratio (VH/VV): Detects water-like scattering (rejects smooth manmade surfaces)
//
// TUNING STRATEGY:
// - Weights (w_*): Control which evidence matters most (should sum to ~1.0)
// - Softness (k_*): Control transition sharpness around Otsu thresholds (dB units)
// - Larger k = gentler (higher recall, more false positives)
// - Smaller k = sharper (higher precision, more false negatives)
//
// TYPICAL ADJUSTMENTS:
// - Urban flooding: Increase w_ratio (0.15→0.25) to reject smooth surfaces
// - Wetland flooding: Increase w_abs (0.35→0.50), decrease w_change (0.50→0.35)
// - Mountain flooding: Decrease k_change (1.5→1.0) for sharper change detection
//
SCORE: {
// Weight for change-based evidence (deltaVV). Good for separating newly flooded from permanent water-like surfaces.
// Higher => more sensitive to change; better for floods over normally bright land.
// Lower => relies more on absolute darkness; can miss floods if baseline already low (e.g., wetlands/rice).
w_change: 0.50,
// Weight for absolute low VV in post.
// Higher => more “water is dark” detector; good for open water flood.
// Too high => can mislabel smooth manmade surfaces, radar shadow, calm water in non-flood contexts.
w_abs: 0.35,
// Weight for VH/VV ratio evidence
// Higher => ratio constraint matters more (stricter water-like scattering requirement)
// Lower => ratio constraint matters less (can miss this evidence type)
w_ratio: 0.15,
// Softness (dB) around the Otsu-derived change threshold.
// Larger => gentler transition -> more pixels get mid-probabilities (higher recall, lower precision).
// Smaller => sharper decision -> crisper boundaries but more brittle to noise / speckle.
k_change: 1.5, // dB
// Softness around the Otsu-derived absolute VV threshold.
// Larger => more inclusive “darkness” evidence.
// Smaller => stricter “must be very dark” evidence.
k_abs: 1.5, // dB
// Softness around the ratio threshold.
// Larger => ratio constraint becomes weak (lets more through).
// Smaller => ratio constraint becomes hard (can reject valid water if VH noisy).
k_ratio: 1.0, // dB
// Threshold for “water-like” VH/VV ratio in dB (VH - VV).
// More negative (e.g., -10) => stricter (only very water-like ratio passes) -> fewer false positives, more misses.
// Less negative (e.g., -6) => looser -> more detections, more false positives (esp. wet vegetation/rough surfaces).
ratioThreshDb: -8.0
}, // End of SCORE
// ============================================================
// COHERENCE: Optional SLC Coherence Evidence (ADVANCED)
// ------------------------------------------------------------
// PURPOSE:
// - Fuse external Sentinel-1 SLC interferometric coherence with GRD flood probability.
// - Coherence measures phase similarity between two SAR acquisitions.
// - Low coherence indicates surface change (flood, landslide, vegetation growth, etc.)
//
// WHAT IS COHERENCE:
// - Range: 0 (completely decorrelated) to 1 (perfectly coherent)
// - High coherence (>0.7): Stable surface (bare soil, rock, urban)
// - Low coherence (<0.3): Surface changed between acquisitions (water, vegetation, snow)
// - Floodwater causes decorrelation → coherence drops
//
// WHY USE COHERENCE (benefits):
// - Complements GRD: GRD detects "dark water"; coherence detects "surface change"
// - Reduces false positives: Smooth surfaces (parking lots, roofs) are dark in GRD but stay coherent
// - Improves urban flood detection: Urban areas have complex GRD signatures; coherence drop is clearer
//
// LIMITATIONS:
// - Requires SLC processing outside GEE (SNAP, ASF HyP3, or custom InSAR processor)
// - Needs two SLC acquisitions with short temporal baseline (<12 days preferred)
// - Coherence can drop for non-flood reasons: wind-blown vegetation, soil moisture, agricultural activity
// - NOT available in GEE by default (must compute externally and upload as asset)
//
// WORKFLOW (if enabled=true):
// 1) Process Sentinel-1 SLC pairs externally:
// - Event pair: Two acquisitions during flood (e.g., Nov 22 & Nov 28)
// - Pre-event pair (optional): Two acquisitions before flood (e.g., Oct 15 & Oct 21)
// 2) Tools: SNAP (free), ASF HyP3 (automated), ISCE2, or Gamma
// 3) Generate coherence GeoTIFF (values 0-1)
// 4) Upload to GEE Assets (Assets tab → New → Image Upload)
// 5) Update eventCohAssetId / preCohAssetId below with your asset paths
// 6) Set enabled=true
//
// FUSION STRATEGY:
// - GRD probability weighted 0.75 (dominant signal)
// - Coherence probability weighted 0.25 (supporting evidence)
// - Final = w_grd * P_grd + w_coh * P_coh
// - Result: Coherence slightly boosts probability where both agree
//
// TUNING COHERENCE THRESHOLDS:
// - lowCohThresh (default 0.25):
// * Lower (0.15-0.20): More sensitive, detects subtle change (more false positives)
// * Higher (0.30-0.40): Stricter, requires strong decorrelation (fewer false positives)
// - cohDropThresh (default 0.15):
// * How much coherence must drop from pre to event to count as "new change"
// * Lower (0.10): Detects subtle changes
// * Higher (0.20-0.25): Only strong decorrelation events
// - k_low / k_drop (softness):
// * Larger = softer transitions (more pixels with medium probability)
// * Smaller = sharper decisions
//
// WHEN TO SKIP COHERENCE (keep enabled=false):
// - No SLC processing capability available
// - Very small AOI where GRD is already sufficient
// - Rapid event (<6 days) where finding good SLC pairs is difficult
// - Vegetated areas where coherence naturally low (dense forest, crops)
//
COH: {
enabled: false, // Set to true ONLY if you have coherence assets uploaded
// Band name in your uploaded coherence GeoTIFF
// SNAP/HyP3 typically output band 'b1' or 'coherence'
cohBand: 'b1', // Check your GeoTIFF metadata; adjust if needed
// A) Event coherence asset (REQUIRED if enabled=true)
// - Coherence computed from SLC pair during flood event
// - Example: 'users/bistanto/aceh_flood_2025_coh_event'
eventCohAssetId: 'users/yourname/lhokseumawe_coh_event_corr',
// B) Pre-event coherence asset (OPTIONAL but recommended)
// - Coherence computed from SLC pair before flood (same season/conditions)
// - Enables coherence drop detection: Δcoh = coh_pre - coh_event
// - Leave empty ('') if unavailable; script will skip coherence drop
preCohAssetId: 'users/yourname/lhokseumawe_coh_pre_corr',
// Evidence thresholds (coherence units: 0-1 scale)
lowCohThresh: 0.25, // Coherence below this → evidence of surface change
// Lower = more sensitive; Higher = stricter
cohDropThresh: 0.15, // Coherence drop (pre - event) above this → evidence of NEW change
// Only used if preCohAssetId provided
// Lower = detects subtle change; Higher = only strong events
// Softness parameters (coherence units: 0-1)
k_low: 0.05, // Softness around lowCohThresh (larger = gentler transition)
k_drop: 0.05, // Softness around cohDropThresh
// Fusion weights: how to combine GRD and coherence probabilities
FUSE: {
w_grd: 0.75, // Weight for GRD-based flood probability (DOMINANT signal)
w_coh: 0.25, // Weight for coherence-based probability (SUPPORTING evidence)
// w_grd + w_coh should = 1.0 for interpretable probabilities
clamp01: true // Clamp final probability to [0,1] range (safety against overshoot)
// Recommended: keep true
}
}, // End of COH
// ============================================================
// PLAUSIBILITY MASKS: PHYSICS-BASED FLOOD CONSTRAINTS
// ------------------------------------------------------------
// PURPOSE:
// - Remove physically implausible flood detections (steep slopes, high terrain, permanent water)
// - Improve precision without sacrificing recall in valid flood zones
//
// MASK TYPES:
// 1) Slope: Removes steep terrain (prone to radar shadow/layover artifacts)
// 2) HAND: Keeps pixels near drainage network (vertical proximity to rivers)
// 3) Permanent water: Excludes lakes/reservoirs (not "flood" but pre-existing water)
//
// TUNING GUIDANCE:
// - Adjust based on flood type (riverine vs coastal vs pluvial)
// - Adjust based on terrain (flat plains vs mountainous valleys)
// - If removing too much real flood: relax thresholds (increase maxSlopeDeg, maxHANDm)
// - If too many false positives remain: tighten thresholds (decrease maxSlopeDeg, maxHANDm)
//
MASKS: {
// Max slope (degrees) allowed for flood.
// Lower (e.g., 3) => stricter: removes mountain/steep artefacts; risk removing true flood on sloping riverbanks.
// Higher (e.g., 8–10) => keeps more terrain; increases shadow/layover false positives.
maxSlopeDeg: 5,
// Max HAND (m) allowed: keeps pixels close (vertically) to drainage network.
// Lower (e.g., 10–15) => very river/floodplain focused; can miss coastal/tidal floods or pluvial ponding away from rivers.
// Higher (e.g., 40–80) => keeps more inland depressions; increases false positives in low-backscatter non-flood areas.
maxHANDm: 25,
// Permanent water occurrence (%) threshold from JRC GSW to mask “already water most of the time”.
// Higher (e.g., 95) => masks only very permanent water; keeps seasonal water (better for flood, but more confusion).
// Lower (e.g., 70–80) => masks lots of seasonal water; can accidentally remove real flood in wetlands/rice paddies.
permWaterOcc: 85
}, // End of MASKS
// ============================================================
// Histogram sampling (Otsu stability across AOI shapes)
// ------------------------------------------------------------
// PURPOSE:
// - Ensure consistent Otsu thresholds regardless of AOI geometry
// - Prevent ocean/permanent water from dominating histogram in coastal areas
//
// PROBLEM:
// - DRAWN bounding box often includes more ocean than GAUL polygon
// - Ocean's very dark VV shifts histogram → different thresholds → inconsistent flood extent
//
// SOLUTION:
// - Build dedicated histogram mask for threshold estimation only:
// (a) Land-like footprint from Copernicus DEM mask
// (b) Optionally exclude permanent water (JRC GSW occurrence >= permWaterOcc)
//
// PERFORMANCE:
// - Enable sampling (useSampling=true) for large AOIs (memory-safe)
// - Adjust sampleScaleM / samplePixels if hitting memory limits
//
HIST: {
enabled: true,
// Use Copernicus DEM footprint as "land-like" sampling area.
// This is especially important for coastal AOIs where a bbox includes ocean.
useCopDemLandMask: true,
// Exclude permanent water from Otsu sampling
// (prevents ocean/lakes from pulling VV_post histogram too dark).
excludePermanentWater: true,
// -------------------------------
// MEMORY-SAFE OTSU SAMPLING
// -------------------------------
// Why:
// - reduceRegion(histogram) can exceed memory for big AOIs or complex masks (DEM+GSW).
// - Sampling caps the work to N pixels, so Otsu cost becomes bounded and predictable.
useSampling: true,
// Sampling scale (meters). Coarser scale = fewer candidates = cheaper.
// 120–250m is a good range: still representative histograms, much lower cost.
sampleScaleM: 200,
// Max number of samples used to build histogram.
// 40k–100k usually plenty for stable Otsu.
samplePixels: 60000,
// Histogram bucketing: 128 is usually enough for Otsu and cheaper than 255.
maxBuckets: 128,
// tileScale helps reduce per-tile memory footprint (higher = more tiles, less memory).
tileScale: 8,
// Diagnostic map layer of histMask
debugLayers: false
}, // End of HIST
// ============================================================
// POST-PROCESSING: FINAL MAP CLEANUP
// ------------------------------------------------------------
// PURPOSE:
// - Convert continuous probability to binary flood mask
// - Remove speckle noise (small isolated pixels)
// - Optionally smooth boundaries (morphological operations)
//
// WORKFLOW:
// 1) Threshold: floodProb >= floodProbThreshold → binary mask
// 2) Connected components: Remove blobs < minConnectedPixels
// 3) Morphological smoothing: Dilation→Erosion (if morphRadiusPx > 0)
//
POST: {
// Final probability threshold to declare “flood”.
// TUNE THIS based on application:
// Lower (e.g., 0.30-0.40) => more flood pixels (higher recall), but more false positives.
// Balanced (e.g., 0.40-0.50) => general use
// Higher (e.g., 0.50–0.60) => cleaner map (higher precision), but misses vegetated/shallow/urban flood.
floodProbThreshold: 0.50,
// Minimum connected component size (in pixels) to keep.
// TUNE THIS based on application:
// Lower (e.g., 1–10) => keeps small ponds / urban puddles; also keeps speckle noise.
// Balanced (e.g., 10–50) => Typical use
// Higher (e.g., 50–200) => removes speckle; can delete genuine narrow river flooding or small inundation patches.
minConnectedPixels: 25,
// Morphological smoothing radius.
// TUNE THIS based on application:
// 0 => no dilation/erosion (keeps raw shapes; good for diagnostics).
// 1–2 => fills holes / cleans edges; can merge nearby water bodies and slightly inflate area.
morphRadiusPx: 1
}, // End of POST
// ============================================================
// METEOROLOGY: Precipitation & Atmospheric Cross-Check
// ------------------------------------------------------------
// PURPOSE:
// - Verify flood plausibility with independent precipitation data
// - Cross-check SAR-detected "water" against rainfall evidence
// - Identify meteorological drivers (tropical cyclones, mesoscale convective systems)
//
// WHY CROSS-CHECK:
// - SAR can detect "dark surfaces" that aren't flood (smooth urban, shadow, wind roughening)
// - Strong precipitation correlation → increases flood confidence
// - No precipitation → suggests non-flood cause (dam release, tide, misdetection)
//
// DATASETS:
//
// 1) IMERG (Integrated Multi-satellitE Retrievals for GPM)
// - Source: NASA GPM (Global Precipitation Measurement) mission
// - Resolution: 0.1° (~11 km), 30-minute temporal
// - Coverage: Global 60°N-60°S
// - Band: 'precipitation' (mm/hr)
// - Best for: Tropical/subtropical flood events, high temporal resolution
// - Limitations: Lower accuracy over complex terrain (mountains, coasts)
//
// 2) ECMWF IFS (Integrated Forecasting System)
// - Source: European Centre for Medium-Range Weather Forecasts
// - Resolution: ~9 km, hourly
// - Coverage: Global
// - Bands available in this script:
// * total_precipitation_rate_sfc: Precip rate (kg m^-2 s^-1, converted to mm/hr)
// * vorticity_pl850: Low-level rotation (850 hPa, helps identify cyclones)
// * u/v_component_of_wind_10m_sfc: 10m wind (helps identify storm systems)
// - Best for: Synoptic-scale diagnostics, identifying atmospheric drivers
// - Limitations: Forecast model (not observations), may have biases
//
// OUTPUT LAYERS:
// - IMERG accumulation: Total rainfall (mm) over event window
// - IFS diagnostics: Mean precip rate, vorticity, wind speed over event window
// - Time series charts: Track precipitation evolution (when did heavy rain occur?)
//
// INTERPRETATION GUIDANCE:
// - SAR flood + 100-300mm IMERG accumulation → HIGH confidence flood
// - SAR flood + <20mm IMERG accumulation → CHECK: dam release? tidal? false positive?
// - SAR flood + strong vorticity (850 hPa) → tropical cyclone/monsoon depression
// - SAR flood + high wind speed → wind-roughened water may reduce VV (interpretation caution)
//
// CUSTOMIZATION:
// - Change imergCollection to 'NASA/GPM_L3/IMERG_MONTHLY_V07' for longer windows
// - Add IFS bands: 'mean_sea_level_pressure_sfc' (pressure systems), 'convective_inhibition_sfc' (storm potential)
//
MET: {
imergCollection: 'NASA/GPM_L3/IMERG_V07', // IMERG V07 30-minute product
imergBand: 'precipitation', // Precipitation rate (mm/hr)
ifsCollection: 'ECMWF/NRT_FORECAST/IFS/OPER', // ECMWF IFS Near-Real-Time operational forecast
imergStepHours: 0.5, // IMERG V07 timestep (30 minutes = 0.5 hours)
// Used to convert rate (mm/hr) → accumulation (mm) per image
// Formula: accum_mm = rate_mmhr * stepHours
// ECMWF IFS bands to extract (can add more from IFS documentation)
ifsBands: [
'total_precipitation_rate_sfc', // Precip rate at surface (kg m^-2 s^-1)
// Converted to mm/hr via *3600 in script
'vorticity_pl850', // Relative vorticity at 850 hPa (s^-1)
// Positive = cyclonic rotation (NH: counterclockwise)
// Useful for identifying tropical cyclones, lows
'u_component_of_wind_10m_sfc', // Zonal wind at 10m (m/s, eastward positive)
'v_component_of_wind_10m_sfc' // Meridional wind at 10m (m/s, northward positive)
// Combined to compute wind speed: sqrt(u² + v²)
// High wind → storm systems, can roughen water surface
]
}, // End of MET
// ============================================================
// EXPORT: Save Outputs to Google Drive (OPTIONAL)
// ------------------------------------------------------------
// PURPOSE:
// - Export final flood maps and diagnostics as GeoTIFF rasters
// - Enables: offline analysis, GIS integration (ArcGIS/QGIS), report generation
//
// EXPORTED LAYERS (when enabled=true):
// 1) FloodProb_Fused_YYYY-MM-DD.tif: Continuous flood probability (0-1, Float32)
// 2) FloodMask_YYYY-MM-DD.tif: Binary flood extent (1=flood, 0=no flood, Byte)
// 3) IMERGaccum_YYYY-MM-DD.tif: Total precipitation accumulation (mm, Float32)
// 4) IFSdiagnostics_YYYY-MM-DD.tif: Multi-band (precip rate, vorticity, wind speed)
//
// WORKFLOW:
// 1) Set enabled=true below
// 2) Run script → check "Tasks" tab in Code Editor (orange icon, top-right)
// 3) Click "RUN" on each export task (they queue but don't auto-start)
// 4) Wait for completion (blue → green checkmark; time depends on AOI size)
// 5) Find outputs in Google Drive → folder specified by 'folder' parameter
//
// PERFORMANCE TUNING:
// - scale (meters per pixel):
// * 10m: Sentinel-1 native resolution (best quality, largest files, slowest export)
// * 20-30m: Faster export, still useful for most applications
// * 100m: Regional overviews, very fast
// - maxPixels: Max allowed pixels per export (GEE limit: ~1e13)
// * If export fails with "too many pixels": Increase scale or reduce AOI size
// - folder: Google Drive folder (created automatically if doesn't exist)
//
// IMPORTANT NOTES:
// - Export does NOT happen automatically; you must click "RUN" in Tasks tab
// - Large AOIs (>5000 km²) at 10m may hit GEE's 50 MB single-file limit
// → Solution: Manually split AOI into tiles, or increase scale
// - Exports stay queued in Tasks tab for 7 days (then auto-cancel if not run)
//
// ALTERNATIVE EXPORT OPTIONS (not in this script):
// - Export.image.toAsset(): Save to GEE Assets (for later reuse in GEE)
// - Export.image.toCloudStorage(): Export to Google Cloud Storage (for large files)
//
EXPORT: {
enabled: false, // Set to true to enable exports (don't forget to RUN tasks!)
folder: 'GEE_FLOOD', // Google Drive folder name (created automatically if missing)
// Path will be: Google Drive → GEE_FLOOD → [exported files]
scale: 10, // Export resolution (meters per pixel)
// TUNE THIS: 10=highest quality/slowest, 20-30=balanced, 100=fast/coarse
maxPixels: 1e13 // Maximum pixels allowed per export (GEE hard limit)
// Increase if you get "too many pixels" error (rare)
// Default 1e13 is usually sufficient
} // End of EXPORT
};
/** =========================
* (Optional) PASTE AOI GEOMETRY
* =========================
* If CONFIG.AOI.mode = 'PASTE' (or 'AUTO' and you want AUTO to pick it),
* paste geometry here as CODE.
*
* IMPORTANT:
* - Place this block ABOVE the `var aoi = getAOI();` line.
* - Comment it out if you use GAUL/ASSET/DRAWN, to avoid confusion.
*/
// var geometry = /* color: #98ff00 */ee.Geometry.Polygon(
// [[[-116.6453125, 40.73451966966921],
// [-121.655078125, 34.90818586132565],
// [-87.90507812500002, 29.022261488477003],
// [-103.95895284764096, 46.26418377057588]]]
// );
// End of CONFIG
/** #############################################
* 1) Helper functions
* #############################################
*/
/**
* Logistic sigmoid for ee.Image.
*
* PURPOSE:
* - Converts hard thresholds into soft probability transitions (0→1).
* - Prevents abrupt "on/off" decisions that are sensitive to noise.
* - Formula: 1/(1+exp(-x))
*
* USAGE IN THIS SCRIPT:
* - Transforms Otsu threshold deviations into smooth flood probabilities.
* - Example: deltaVV = -3 dB below threshold → sigmoid → ~0.88 probability
* deltaVV = +3 dB above threshold → sigmoid → ~0.12 probability
*
* WHY SIGMOID vs HARD THRESHOLD:
* - Hard: if (deltaVV < threshold) → 1, else → 0 [brittle, speckle-sensitive]
* - Soft: sigmoid((threshold - deltaVV) / k) [gradual, robust to noise]
*
* @param {ee.Image} x - input image
* @return {ee.Image} 1/(1+exp(-x))
*/
function sigmoid(x) {
return x.multiply(-1).exp().add(1).pow(-1);
}
/**
* Safe, server-side label for printing without getInfo().
*
* PURPOSE:
* - Builds human-readable labels entirely server-side (no client getInfo() calls).
* - Avoids triggering computations or hitting API rate limits.
*
* WHY SERVER-SIDE STRINGS:
* - Using date.format() keeps dates as ee.String (not client-side JS strings).
* - Prevents accidental .getInfo() cascades that slow down script loading.
*
* EXAMPLE OUTPUT:
* "S1 images (ASCENDING) 2025-10-01 → 2025-11-01"
*
* @param {String} orbit
* @param {ee.Date} start
* @param {ee.Date} end
* @return {ee.String}
*/
function labelOrbitWindow(orbit, start, end) {
return ee.String('S1 images (').cat(orbit).cat(') ')
.cat(start.format('YYYY-MM-dd')).cat(' → ').cat(end.format('YYYY-MM-dd'));
}
/**
* Build a "land-like" mask using Copernicus DEM footprint.
*
* PURPOSE:
* - Create a mask that represents areas where DEM data exists (typically land, not ocean)
* - Used to stabilize Otsu threshold estimation in coastal AOIs
*
* WHY THIS MATTERS:
* - Problem: DRAWN bounding boxes often include large ocean areas
* - Ocean pixels have very dark VV (-25 to -30 dB) → dominate histogram
* - Result: Otsu threshold shifts darker → inconsistent flood detection
* - Solution: Sample histogram only over land-like areas (where DEM exists)
*
* HOW IT WORKS:
* - Copernicus DEM GLO-30 has valid data over land, masked over open ocean
* - We use the DEM's own mask as a "land footprint" proxy
* - No elevation values used, just the presence/absence of DEM data
*
* SAFETY FEATURES:
* - Checks if DEM tiles exist in AOI before processing
* - Falls back to "all ones" (no restriction) if DEM missing
* - Prevents crashes in edge cases (polar regions, small islands)
*
* PERFORMANCE NOTES:
* - Does NOT clip here (clip deferred to buildHistogramMask)
* - Clipping early can bloat expression graph in large AOIs
*
* @param {ee.Geometry} aoi - area of interest
* @return {ee.Image} mask image (1 where DEM valid; masked elsewhere)
*/
function buildCopDemLandMask(aoi) {
var demIC = ee.ImageCollection('COPERNICUS/DEM/GLO30')
.filterBounds(aoi)
.select('DEM');
var hasDem = demIC.size().gt(0);
// IMPORTANT:
// - Do NOT clip here (clip is a geometry op and can bloat the expression graph).
// - Just use the DEM footprint mask; we’ll clip once at the very end of histMask.
var landMask = ee.Image(ee.Algorithms.If(
hasDem,
demIC.mosaic().mask(), // Use DEM's native mask (valid over land, masked over ocean)
ee.Image(1) // Fallback: no restriction (prevents crash if DEM missing)
)).rename('landMask');
return landMask;
}
/**
* Build the histogram sampling mask used ONLY for Otsu threshold estimation.
*
* PURPOSE:
* - Create a restricted sampling area for computing Otsu thresholds
* - Ensures consistent thresholds across different AOI geometries (GAUL vs DRAWN bbox)
*
* CRITICAL DISTINCTION:
* - This mask is NOT applied to final flood probability computation
* - It is ONLY used to compute stable Otsu thresholds (tAbs, tChange)
* - Final probability is computed over the full AOI, then masked by plausibility constraints
*
* WHY THIS EXISTS:
* - Problem: Otsu thresholds depend on the histogram of your AOI
* - DRAWN bounding box: includes ocean → ocean's dark VV dominates histogram
* - GAUL admin polygon: excludes most ocean → histogram represents land
* - Result: Same location, different AOI shape → different thresholds → different flood extent
* - Solution: Force both to use same "land-like + not-permanent-water" sampling mask
*
* MASK COMPONENTS (configurable in CONFIG.HIST):
* (A) Copernicus DEM footprint: "land-like" area (excludes open ocean)
* (B) Exclude permanent water: GSW occurrence >= permWaterOcc (excludes lakes/rivers)
* Result: Histogram sampled from "land that could flood" (not ocean, not permanent water)
*
* EXAMPLE IMPACT:
* - AOI = coastal city
* - DRAWN bbox: 40% ocean, 10% lakes, 50% land → Otsu threshold = -18 dB (too dark)
* - With histMask: 0% ocean, 0% lakes, 100% land → Otsu threshold = -12 dB (stable)
* - Result: Consistent flood detection regardless of bbox vs polygon
*
* WHEN TO DISABLE (CONFIG.HIST.enabled = false):
* - Small inland AOIs where ocean/permanent water not an issue
* - When you want ocean included in threshold estimation (rare)
*
* @param {ee.Geometry} aoi - area of interest
* @return {ee.Image} histMask (masked image), or null if CONFIG.HIST.enabled=false
*/
function buildHistogramMask(aoi) {
// If HIST config missing or disabled: return null (caller decides fallback).
if (!CONFIG.HIST || !CONFIG.HIST.enabled) return null;
// Start from "no restriction" mask.
// We'll refine it based on config toggles below.
var m = ee.Image(1);
// (A) Land-like sampling from Copernicus DEM footprint
if (CONFIG.HIST.useCopDemLandMask) {
var land = buildCopDemLandMask(aoi);
// land is already a mask-y image (masked over ocean); updateMask respects its mask.
m = m.updateMask(land);
}
// (B) Exclude permanent water from histogram sampling
if (CONFIG.HIST.excludePermanentWater) {
var gswOcc = ee.Image('JRC/GSW1_4/GlobalSurfaceWater')
.select('occurrence');
// Make "not permanent water" a true mask (0 becomes masked)
// lt(permWaterOcc) means "occurrence < threshold" → not permanent
var notPermMask = gswOcc.lt(CONFIG.MASKS.permWaterOcc).selfMask();
m = m.updateMask(notPermMask);
}
// Single clip at end (cheaper than many clips)
// Result: mask is 1 where valid, masked elsewhere
return m.clip(aoi).rename('histMask');
}
/**
* Build a histogram dictionary safely with automatic fallbacks.
*
* PURPOSE:
* - Compute histogram for Otsu threshold estimation
* - Handle edge cases gracefully (empty collections, null results)
* - Route to memory-safe sampling if configured
*
* TWO STRATEGIES:
* 1) Sampling-based (CONFIG.HIST.useSampling=true):
* - Random sample N pixels at coarser scale
* - Memory-bounded, predictable cost
* - RECOMMENDED for large AOIs (>1000 km²)
*
* 2) Full reduceRegion (CONFIG.HIST.useSampling=false):
* - Histogram over all unmasked pixels
* - More accurate for small AOIs
* - Can hit memory limits on large/complex AOIs
*
* WHY SAFE:
* - Problem: reduceRegion can return null if no valid pixels (e.g., AOI entirely masked)
* - Consequence: Otsu function crashes on null histogram
* - Solution: Return dummy histogram {histogram: [1], bucketMeans: [0]} if null
* - Result: Otsu returns threshold=0 (harmless fallback, flood probability stays low)
*
* OUTPUT FORMAT:
* Dictionary with two keys:
* - 'histogram': Array of pixel counts per bucket
* - 'bucketMeans': Array of mean values per bucket
* Example: {histogram: [100, 500, 300], bucketMeans: [-20, -15, -10]}
*
* PERFORMANCE TUNING:
* - If hitting memory errors: Set CONFIG.HIST.useSampling=true
* - Adjust CONFIG.HIST.sampleScaleM (coarser = cheaper)
* - Adjust CONFIG.HIST.samplePixels (fewer = cheaper)
*
* @param {ee.Image} img - single-band image to histogram
* @param {String} bandName - band name (e.g., 'VV', 'deltaVV')
* @param {ee.Geometry} aoi - area of interest
* @param {Number} scale - pixel scale for reduceRegion (meters)
* @param {Number} maxBuckets - number of histogram bins
* @return {ee.Dictionary} {'histogram': [...], 'bucketMeans': [...]}
*/
function safeHistogram(img, bandName, aoi, scale, maxBuckets) {
// If sampling is enabled, use bounded sampling (memory-safe).
if (CONFIG.HIST && CONFIG.HIST.enabled && CONFIG.HIST.useSampling) {
return safeHistogramSampled(img, bandName, aoi);
}
// Fallback: original reduceRegion histogram (OK for small AOIs)
var rr = img.reduceRegion({
reducer: ee.Reducer.histogram({maxBuckets: maxBuckets || 255}),
geometry: aoi,
scale: scale || 30,
bestEffort: true, // Allow coarser scale if needed to avoid timeout
maxPixels: 1e8, // Safety limit
tileScale: 4 // Process in smaller tiles (reduces memory per tile)
});
var h = rr.get(bandName);
// Safety: if h is null (no valid pixels), return dummy histogram
// This prevents Otsu from crashing on edge cases
return ee.Dictionary(ee.Algorithms.If(
h,
h,
ee.Dictionary({histogram: [1], bucketMeans: [0]}) // Dummy: one bucket at value 0
));
}
/**
* Memory-safe histogram via bounded random sampling.
*
* PURPOSE:
* - Compute histogram for Otsu without exceeding GEE memory limits
* - Use bounded random sampling instead of full reduceRegion
*
* WHY SAMPLING:
* - Problem: reduceRegion(histogram) on large AOIs can hit "Memory capacity exceeded"
* - Especially with complex masks (DEM footprint + permanent water exclusion)
* - Sampling bounds cost to N pixels regardless of AOI size
*
* STRATEGY:
* 1) Random sample up to N pixels at coarser scale (e.g., 200m)
* 2) Run histogram reducer on the sampled FeatureCollection
* 3) Result: representative histogram, predictable memory cost
*
* SAMPLING PARAMETERS (from CONFIG.HIST):
* - sampleScaleM: Pixel spacing for sampling (200m default)
* * Coarser scale → fewer candidate pixels → faster
* * Still representative: 200m sampling gives ~60k pixels over 2400 km²
* - samplePixels: Max number of samples (60k default)
* * More samples → more accurate histogram → slower
* * 40k-100k is the sweet spot for stable Otsu
* - maxBuckets: Histogram bins (128 default)
* * Fewer bins → coarser histogram → faster
* * 128 is plenty for Otsu (cheaper than 255)
* - tileScale: Subdivide into more tiles (8 default)
* * Higher → more tiles → less memory per tile
*
* OUTPUT FORMAT:
* Matches reduceRegion(histogram): {histogram: [...], bucketMeans: [...]}
*
* PERFORMANCE:
* - Typical cost: 200m scale, 60k samples → ~2-5 seconds for large AOI
* - Compare to: Full reduceRegion on same AOI → "Memory exceeded" error
*
* ACCURACY:
* - Otsu is robust to sampling: 60k samples gives nearly identical threshold to full histogram
* - Tested: <0.5 dB difference in threshold for most AOIs
*
* @param {ee.Image} img - single-band image to histogram
* @param {String} bandName - band name (e.g., 'VV', 'deltaVV')
* @param {ee.Geometry} aoi - area of interest
* @return {ee.Dictionary} {'histogram': [...], 'bucketMeans': [...]}
*/
function safeHistogramSampled(img, bandName, aoi) {
// Read parameters from CONFIG (with sensible defaults)
var scaleM = CONFIG.HIST.sampleScaleM || 200; // Sampling grid spacing (m)
var nPix = CONFIG.HIST.samplePixels || 60000; // Max samples to use
var buckets = CONFIG.HIST.maxBuckets || 128; // Histogram bins
var tileScale = CONFIG.HIST.tileScale || 8; // Tile subdivision factor
// Sample only unmasked pixels
// If histMask is applied to img, this respects it (samples only from valid land areas)
var fc = img.select([bandName]).sample({
region: aoi,
scale: scaleM, // Coarser scale = fewer candidate pixels
numPixels: nPix, // Cap at this many samples (memory-bounded)
geometries: false, // Don't store geometries (saves memory)
tileScale: tileScale // Process in smaller tiles
});
var has = fc.size().gt(0);
// Build histogram from sampled FeatureCollection
// reduceColumns returns: {histogram: {histogram: [...], bucketMeans: [...], ...}}
var stats = ee.Dictionary(ee.Algorithms.If(
has,
fc.reduceColumns({
reducer: ee.Reducer.histogram({maxBuckets: buckets}),
selectors: [bandName]
}),
// Safety: if no samples (shouldn't happen), return dummy histogram
ee.Dictionary({histogram: ee.Dictionary({histogram: [1], bucketMeans: [0]})})
));
// Extract the nested histogram dictionary
var h = ee.Dictionary(stats.get('histogram'));
// Final safety: if anything is still null-ish, return dummy hist.
return ee.Dictionary(ee.Algorithms.If(
h,
h,
ee.Dictionary({histogram: [1], bucketMeans: [0]})
));
}
/**
* Otsu threshold from an Earth Engine histogram dictionary (single-band).
*
* ALGORITHM:
* - Otsu's method finds the optimal threshold that maximizes between-class variance (BCV).
* - Splits histogram into two classes at each possible threshold, computes variance separation.
* - Returns threshold at split point with maximum BCV.
*
* WHY OTSU FOR FLOOD MAPPING:
* - Automatically adapts to each AOI's backscatter distribution (no manual threshold tuning).
* - Assumes bimodal histogram (dark water vs bright land) → robust for change detection.
* - Works well when flood creates clear separation in VV or deltaVV histograms.
*
* ROBUSTNESS FEATURES:
* - Handles edge cases: 0-bucket (returns 0), 1-bucket (returns that value).
* - Computes BCV only over valid split points 1..(size-1) to avoid empty classes.
* - Safe against null/undefined histograms (returns fallback values).
*
* INPUT FORMAT (from reduceRegion or reduceColumns):
* {
* histogram: [count1, count2, ...], // pixel counts per bucket
* bucketMeans: [val1, val2, ...] // mean value per bucket
* }
*
* LIMITATIONS:
* - Fails on unimodal histograms (e.g., all water or all land) → picks arbitrary threshold.
* - Sensitive to histogram mask (ocean inclusion shifts threshold) → use HIST.enabled=true.
*
* @param {ee.Dictionary} hist - dict with 'histogram' and 'bucketMeans'
* @return {ee.Number} optimal threshold value
*/
function otsu(hist) {
hist = ee.Dictionary(hist);
var counts = ee.Array(hist.get('histogram'));
var means = ee.Array(hist.get('bucketMeans'));
var size = ee.Number(means.length().get([0]));
// Edge case handling
var threshold = ee.Algorithms.If(
size.eq(0),
ee.Number(0), // No buckets → return 0
ee.Algorithms.If(
size.eq(1),
ee.Number(means.get([0])), // Single bucket → return that value
_otsuCore(counts, means, size)
)
);
return ee.Number(threshold);
}
/**
* Core Otsu computation (expects size >= 2).
*
* IMPLEMENTATION:
* - Iterate over all possible split points i = 1..(size-1)
* - For each split:
* - Class 1 (lower): buckets 0..(i-1)
* - Class 2 (upper): buckets i..(size-1)
* - Compute between-class variance: BCV = w1 * w2 * (μ1 - μ2)²
* - Return threshold at split with maximum BCV
*
* WHY BCV (not within-class variance):
* - BCV directly measures separation between classes → larger BCV = better split.
* - Equivalent to minimizing within-class variance but simpler to compute.
*
* NUMERICAL STABILITY:
* - Adds 1e-6 to totals to prevent division by zero (shouldn't happen with valid histograms).
*
* @param {ee.Array} counts - pixel counts per bucket
* @param {ee.Array} means - mean value per bucket
* @param {ee.Number} size - number of buckets (>=2)
* @return {ee.Number} threshold at optimal split point
*/
function _otsuCore(counts, means, size) {
var total = ee.Number(counts.reduce(ee.Reducer.sum(), [0]).get([0])).add(1e-6);
// Compute BCV for each split point
var bcvList = ee.List.sequence(1, size.subtract(1)).map(function(i) {
i = ee.Number(i);
// Class 1: buckets [0, i)
var c1 = counts.slice(0, 0, i);
var m1 = means.slice(0, 0, i);
// Class 2: buckets [i, size)
var c2 = counts.slice(0, i);
var m2 = means.slice(0, i);
// Class weights
var s1 = ee.Number(c1.reduce(ee.Reducer.sum(), [0]).get([0])).add(1e-6);
var s2 = ee.Number(c2.reduce(ee.Reducer.sum(), [0]).get([0])).add(1e-6);
// Class weights
var w1 = s1.divide(total);
var w2 = s2.divide(total);
// Class means
var mu1 = ee.Number(m1.multiply(c1).reduce(ee.Reducer.sum(), [0]).get([0])).divide(s1);
var mu2 = ee.Number(m2.multiply(c2).reduce(ee.Reducer.sum(), [0]).get([0])).divide(s2);
// Between-class variance: BCV = w1 * w2 * (μ1 - μ2)²
return w1.multiply(w2).multiply(mu1.subtract(mu2).pow(2));
});
// Find split with maximum BCV
var maxVal = ee.Number(bcvList.reduce(ee.Reducer.max()));
var idx0 = ee.Number(bcvList.indexOf(maxVal)); // 0-indexed in list (split i=1 is at idx=0)
var idx = idx0.add(1); // Convert to bucket index
return ee.Number(means.get([idx]));
}
/**
* AOI resolver (FLEXIBLE)
*
* getAOI(): Multi-mode AOI resolver with rich debug output.
*
* Modes:
* - 'GAUL' : GAUL 2024 L2 lookup (gaul0/1/2_name) with dynamic fallbacks
* - 'ASSET' : user asset FeatureCollection (CONFIG.AOI.ASSET.assetId)
* - 'DRAWN' : geometry drawn in UI (Imports panel), typically variable named `geometry`
* - 'PASTE' : geometry pasted as CODE: var geometry = ee.Geometry...
* - 'AUTO' : ASSET (if provided) -> PASTE (if geometry var exists) -> GAUL
*
* Why both DRAWN and PASTE use `geometry`?
* - In GEE Code Editor, both features commonly create a variable named `geometry`.
* - We let CONFIG decide the user intent.
*
* Debug prints (when CONFIG.AOI.debug = true):
* - Which mode used, and availability flags
* - GAUL property names
* - GAUL matched names + candidate district list under province
*
* @return {ee.Geometry}
*/
function getAOI() {
/** @return {Boolean} true if there is a variable named `geometry` in the script context. */
function hasGeometryVar() {
return (typeof geometry !== 'undefined');
}
/**
* Convert any geometry-like object into ee.Geometry and optionally buffer it.
* @param {ee.Geometry} geom
* @param {Number} bufferM
* @return {ee.Geometry}
*/
function toBufferedGeometry(geom, bufferM) {
geom = ee.Geometry(geom);
bufferM = bufferM || 0;
return bufferM > 0 ? geom.buffer(bufferM) : geom;
}
/**
* AOI from ASSET FeatureCollection.
* @return {ee.Geometry}
*/
function aoiFromAsset() {
var fc = ee.FeatureCollection(CONFIG.AOI.ASSET.assetId);
return toBufferedGeometry(fc.geometry(), CONFIG.AOI.ASSET.bufferM);
}
/**
* AOI from PASTED geometry code: var geometry = ee.Geometry...
* @return {ee.Geometry}
*/
function aoiFromPaste() {
return toBufferedGeometry(ee.Geometry(geometry), CONFIG.AOI.PASTE.bufferM);
}
/**
* AOI from DRAWN geometry in Code Editor (Imports panel).
* @return {ee.Geometry}
*/
function aoiFromDrawn() {
return toBufferedGeometry(ee.Geometry(geometry), CONFIG.AOI.DRAWN.bufferM);
}
/**
* AOI from GAUL 2024 L2 with debuggable matching + dynamic fallback.
* @return {ee.Geometry}
*/
function aoiFromGAUL() {
var gaul = CONFIG.AOI.GAUL;
var fc = ee.FeatureCollection(gaul.adminDataset);
if (CONFIG.AOI.debug) {
print('GAUL 2024 L2 property names (first feature):',
ee.Feature(fc.first()).propertyNames());
}
// Stepwise filtering
var c0 = fc.filter(ee.Filter.eq('gaul0_name', gaul.gaul0_name));
var c1 = c0.filter(ee.Filter.eq('gaul1_name', gaul.gaul1_name));
var c2 = c1.filter(ee.Filter.eq('gaul2_name', gaul.gaul2_name));
// Debug prints (values + candidates)
if (CONFIG.AOI.debug) {
print('GAUL0 matched gaul0_name:', c0.aggregate_array('gaul0_name').distinct());
print('GAUL1 matched gaul1_name (within gaul0):', c1.aggregate_array('gaul1_name').distinct());
print('GAUL2 matched gaul2_name (within gaul1):', c2.aggregate_array('gaul2_name').distinct());
var cand2 = c1.aggregate_array('gaul2_name').distinct().sort()
.slice(0, CONFIG.AOI.maxList);
print('GAUL2 candidates under province (first ' + CONFIG.AOI.maxList + '):', cand2);
print('GAUL match counts:', ee.Dictionary({
gaul0: c0.size(),
gaul1: c1.size(),
gaul2: c2.size()
}));
}
// Best: exact district/city geometry
var geomExact = ee.Feature(c2.first()).geometry();
// Dynamic fallbacks:
// - if gaul2 missing: province geometry bounds -> centroid -> buffer
// - if province missing: country geometry bounds -> centroid -> buffer
var geomProvFallback = c1.geometry().bounds(1000).centroid(1000).buffer(CONFIG.AOI.fallbackBufferM);
var geomCtryFallback = c0.geometry().bounds(5000).centroid(5000).buffer(CONFIG.AOI.fallbackBufferM);
// Last resort
var geomLastResort = ee.Geometry.Point([0, 0]).buffer(1000);
// Choose best available
var geom = ee.Algorithms.If(
c2.size().gt(0), geomExact,
ee.Algorithms.If(
c1.size().gt(0), geomProvFallback,
ee.Algorithms.If(
c0.size().gt(0), geomCtryFallback,
geomLastResort
)
)
);
if (CONFIG.AOI.debug) {
var used = ee.Algorithms.If(
c2.size().gt(0), 'GAUL: gaul2 exact',
ee.Algorithms.If(
c1.size().gt(0), 'GAUL fallback: province centroid+buffer',
ee.Algorithms.If(
c0.size().gt(0), 'GAUL fallback: country centroid+buffer',
'GAUL fallback: [0,0] (CHECK gaul0_name spelling)'
)
)
);
print('AOI selection used:', used);
}
return ee.Geometry(geom);
}
// -------------------------
// Resolve mode
// -------------------------
var mode = (CONFIG.AOI.mode || 'AUTO').toUpperCase();
// Client-side availability checks
var assetProvided = (CONFIG.AOI.ASSET.assetId && CONFIG.AOI.ASSET.assetId.length > 0);
var geomProvided = hasGeometryVar();
// AUTO priority: ASSET → PASTE (if geometry exists) → GAUL
var chosen = mode;
if (mode === 'AUTO') {
chosen = assetProvided ? 'ASSET'
: geomProvided ? 'PASTE'
: 'GAUL';
}
if (CONFIG.AOI.debug) {
print('AOI mode requested:', mode, '→ AOI mode used:', chosen);
if (mode === 'AUTO') print('AUTO availability:', {assetProvided: assetProvided, geometryProvided: geomProvided});
}
if (chosen === 'ASSET') return assetProvided ? aoiFromAsset() : aoiFromGAUL();
if (chosen === 'PASTE') return geomProvided ? aoiFromPaste() : aoiFromGAUL();
if (chosen === 'DRAWN') return geomProvided ? aoiFromDrawn() : aoiFromGAUL();
// Default: GAUL
return aoiFromGAUL();
}
/** #############################################
* 2) Data preparation
* #############################################
*
* OVERVIEW:
* - Preprocess individual Sentinel-1 GRD images (gamma0, edge masking)
* - Build temporal composites (pre-event baseline, post-event flood)
* - Handle empty collections gracefully (prevents crashes)
*
* WORKFLOW:
* 1) preprocessS1(): Apply corrections to each S1 image
* 2) buildS1Composite(): Aggregate corrected images into single composite per orbit/window
* 3) Used in MAIN: Build 4 composites (pre/post × ASCENDING/DESCENDING)
*
* KEY DESIGN CHOICES:
* - Gamma0 correction: Reduces incidence angle brightness gradient
* - Edge masking: Removes border artifacts (scalloping)
* - Temporal reduction: Median (pre) for stability, Min (post) for peak flood
* - Empty collection handling: Masked placeholder prevents crashes
*/
/**
* Preprocess Sentinel-1 GRD: corrections, masking, and band selection.
*
* STEPS:
* 1) Select VV, VH (dB), and incidence angle (degrees)
* 2) OPTIONAL: Simple gamma0 angle correction (reduces brightness gradient across swath)
* 3) OPTIONAL: Border/edge masking (removes low VV noise at scene edges)
*
* BAND CONVENTIONS:
* - VV, VH: Already in dB (decibels, log scale) in COPERNICUS/S1_GRD
* - angle: Incidence angle in degrees (20°-45° for IW mode)
* - No unit conversion needed; GEE provides dB directly
* - Typical ranges: VV = -25 to 5 dB, VH = -35 to -5 dB (depends on target)
*
* WHY GAMMA0 CORRECTION:
* - Raw sigma0 has brightness gradient: near range (steep angle) brighter than far range.
* - Formula: gamma0_dB ≈ sigma0_dB - 10*log10(cos(angle))
* - Result: More uniform backscatter across swath → more stable Otsu thresholds.
* - LIMITATION: This is NOT full terrain flattening; layover/shadow still present in mountains.
*
* WHY EDGE MASKING:
* - GRD scenes have border artifacts ("scalloping") with anomalously low VV.
* - These dark edges can mimic water → false positives.
* - Masking pixels with VV < edgeDbThreshold (e.g., -35 dB) removes most border noise.
* - TRADE-OFF: May remove real dark water if it's genuinely < -35 dB (rare but possible).
*
* MASKING STRATEGY:
* - Apply mask to VV only (VH can be noisier at edges; ratio constraint handles VH quality).
* - updateMask() propagates: masked pixels become transparent in all downstream ops.
*
* OUTPUT:
* - Image with bands: VV (dB), VH (dB), angle (degrees)
* - Preserves original metadata (system:time_start, orbitProperties_pass, etc.)
*
* EXAMPLE VALUES (typical):
* INPUT (raw sigma0):
* - VV: -12 dB (near range, bright) to -18 dB (far range, darker)
* - VH: -20 dB (near range) to -26 dB (far range)
* - angle: 30° (near range) to 45° (far range)
*
* OUTPUT (after gamma0 correction):
* - VV: -11.5 dB (near range) to -16.5 dB (far range) — gradient reduced
* - VH: -19.5 dB (near range) to -24.5 dB (far range) — gradient reduced
* - angle: unchanged (30° to 45°)
*
* EDGE MASKING (edgeDbThreshold = -35 dB):
* - Keeps: VV > -35 dB (most valid land/water)
* - Masks: VV <= -35 dB (border scalloping artifacts)
*
* @param {ee.Image} img - Sentinel-1 GRD image
* @return {ee.Image} preprocessed image with VV,VH (dB) + angle (deg)
*/
function preprocessS1(img) {
var vv = img.select('VV');
var vh = img.select('VH');
var ang = img.select('angle');
// Edge mask: mask extremely low VV (border noise)
var mask = ee.Image(1);
if (CONFIG.S1.clipEdges) {
mask = vv.gt(CONFIG.S1.edgeDbThreshold);
// Result: pixels with VV <= -35 dB (or custom threshold) are masked out
}
// Approx gamma0 correction using incidence angle only:
// gamma0_dB = sigma0_dB - 10*log10(cos(theta))
if (CONFIG.S1.applyGamma0AngleCorrection) {
// Convert angle (degrees) to radians
var theta = ang.multiply(Math.PI / 180.0);
// Correction term: 10*log10(cos(theta))
var corr = theta.cos().log10().multiply(10);
// Apply correction: gamma0_dB = sigma0_dB - correction
vv = vv.subtract(corr);
vh = vh.subtract(corr);
// Physical interpretation:
// - Near range (small angle, ~20°): cos(20°) ≈ 0.94 → correction ≈ -0.27 dB → VV increases
// - Far range (large angle, ~45°): cos(45°) ≈ 0.71 → correction ≈ -1.49 dB → VV increases more
// - Result: Far range brightens more, reducing the brightness gradient
}
// Return preprocessed bands with mask applied
return ee.Image.cat([vv.rename('VV'), vh.rename('VH'), ang.rename('angle')])
.updateMask(mask)
.copyProperties(img, img.propertyNames());
}
/**
* Build a robust Sentinel-1 composite (pre or post) for a given orbit direction.
*
* PURPOSE:
* - Aggregate multiple S1 acquisitions into a single representative image.
* - Handles empty collections gracefully (returns masked placeholder → prevents crashes).
*
* FILTERING:
* 1) Bounds: AOI intersection
* 2) Date: [start, end) window
* 3) Instrument mode: 'IW' (Interferometric Wide swath) for consistent geometry
* 4) Orbit pass: 'ASCENDING' or 'DESCENDING' (processed separately to reduce geometry artifacts)
* 5) Polarizations: Must have both VV and VH
*
* REDUCER STRATEGIES:
* - 'median' (RECOMMENDED for pre-event baseline):
* Robust to outliers (wet days, noise bursts), stable reference for change detection.
* - 'mean':
* Smoother but can be biased by extreme values; less common for SAR.
* - 'min' (RECOMMENDED for post-event flood):
* Captures the darkest backscatter in the window → "peak flood extent" selector.
* CRITICAL for floods: one very dark acquisition indicates water presence.
* RISK: Also captures wind-roughened water, shadow pockets → needs plausibility masking.
* - 'max':
* Sometimes useful for detecting bright targets (urban, ice) but not for floods.
*
* ROBUSTNESS AGAINST EMPTY COLLECTIONS:
* - Problem: If no S1 images exist (wrong orbit, date range, or AOI), downstream ops crash.
* * Error type: "Image.select: Pattern 'VV' did not match any bands"
* * Cause: Empty collection → no image to reduce → missing bands
* - Solution: Return a fully-masked placeholder image with correct band names.
* * Placeholder values: VV=-999, VH=-999, angle=0 (arbitrary; will be masked)
* * updateMask(0) makes all pixels transparent (masked out)
* - Result: Flood probability becomes masked (no false detections from missing data).
* * Downstream ops see correct band names but zero valid pixels
* * floodProbability() returns masked probability → applyMasksAndPost() returns empty flood mask
* - Why this matters: Graceful degradation instead of hard crash
* * Script completes successfully (just with empty results)
* * User sees Console print "0 images" and knows to check date/orbit/AOI
*
* DEBUGGING:
* - Prints image count per orbit/window to Console → verify acquisitions exist.
* - If count = 0, check:
* - Date range (S1 launched 2014; full coverage by ~2015)
* - Orbit direction (some regions have sparse ASCENDING or DESCENDING coverage)
* - AOI location (polar regions / small islands may have gaps)
*
* EXPECTED ACQUISITION COUNTS (60-day window):
* Region ASCENDING DESCENDING Total
* Europe 10-12 10-12 20-24
* North America 6-8 6-8 12-16
* Southeast Asia 5-7 5-7 10-14
* Polar regions (>70° lat) 15-20 15-20 30-40 (frequent coverage)
* Small islands 2-4 2-4 4-8 (may have gaps)
*
* If your counts are significantly lower:
* - Check instrumentMode filter (should be 'IW' for land)
* - Check polarization filter (need both VV and VH)
* - Consider extending baseline window (60 → 90 days)
*
* @param {ee.Geometry} aoi - area of interest
* @param {String} orbit - 'ASCENDING' or 'DESCENDING'
* @param {ee.Date} start - window start (inclusive)
* @param {ee.Date} end - window end (exclusive)
* @param {String} reducer - 'median'|'mean'|'min'|'max'
* @return {ee.Image} composite with VV, VH, angle (all in dB except angle in deg)
*/
function buildS1Composite(aoi, orbit, start, end, reducer) {
// Allow older configs to still work:
// - If reducer passed in, use it
// - Else fall back to CONFIG.S1.temporalReducer (if it exists)
// - Else default to 'median'
reducer = reducer || CONFIG.S1.temporalReducer || 'median';
// Build filtered collection
var ic = ee.ImageCollection(CONFIG.S1.collection)
.filterBounds(aoi)
.filterDate(start, end)
.filter(ee.Filter.eq('instrumentMode', CONFIG.S1.instrumentMode))
.filter(ee.Filter.eq('orbitProperties_pass', orbit))
.filter(ee.Filter.listContains('transmitterReceiverPolarisation', 'VV'))
.filter(ee.Filter.listContains('transmitterReceiverPolarisation', 'VH'))
.map(preprocessS1);
// Debug: print image count (helps diagnose empty collection issues)
print(labelOrbitWindow(orbit, start, end), ic.size(), 'reducer=', reducer);
var has = ic.size().gt(0);
// Choose reducer safely (client-side string switch; output is server-side ee.Image)
// Why client-side switch: ee.Algorithms.If requires server-side logic, but string
// comparison is client-side, so we build a nested If structure that gets evaluated
// server-side once deployed. This avoids .getInfo() calls.
var reduced = ee.Image(
ee.Algorithms.If(
reducer === 'mean', ic.mean(),
ee.Algorithms.If(
reducer === 'min', ic.min(),
ee.Algorithms.If(
reducer === 'max', ic.max(),
ic.median() // default fallback
)
)
)
);
// Safety: if collection is empty, return fully-masked placeholder
// This prevents "band not found" errors and ensures no false flood detections
var placeholder = ee.Image.constant([-999, -999, 0])
.rename(['VV', 'VH', 'angle'])
.updateMask(ee.Image(0)); // Fully masked → transparent in all ops
var comp = ee.Image(ee.Algorithms.If(has, reduced, placeholder));
return comp.clip(aoi);
}
/** #############################################
* 3) Flood probability model (GRD ensemble)
* #############################################
*
* OVERVIEW:
* - Core flood detection algorithm using Sentinel-1 GRD backscatter
* - Ensemble approach: combines three complementary evidence types
* - Optional coherence fusion for improved accuracy
* - Physics-based masking to remove implausible detections
*
* WORKFLOW:
* 1) floodProbability(): Compute continuous probability (0-1) from pre/post composites
* 2) coherenceEvidence(): (Optional) Add SLC coherence evidence
* 3) fuseFloodProbability(): Blend GRD + coherence probabilities
* 4) applyMasksAndPost(): Apply plausibility masks + post-processing
*
* KEY INNOVATION:
* - NOT a single hard threshold (brittle, noisy)
* - Ensemble of soft probabilities (robust, interpretable)
* - Automatic threshold adaptation via Otsu (no manual tuning)
*
* EVIDENCE TYPES:
* 1) Change (ΔVV): Newly darkened pixels → flood over normally bright land
* 2) Absolute (VV_post): Intrinsically dark pixels → open water detection
* 3) Ratio (VH/VV): Water-like scattering → rejects smooth manmade surfaces
* (4) Coherence (optional): Surface change → complements GRD darkness
*/
/**
* Compute ensemble flood probability from pre & post composites (single orbit).
*
* PURPOSE:
* - Transform backscatter evidence into continuous flood probability (0-1)
* - Combine three complementary evidence types with soft thresholds
* - Automatically adapt thresholds to each AOI via Otsu
*
* ALGORITHM:
* 1) Compute evidence images:
* - ΔVV = VV_post - VV_pre (change in backscatter, dB)
* - VV_post (absolute post-event backscatter, dB)
* - VH/VV ratio = VH_post - VV_post (water-like scattering, dB)
* 2) Estimate thresholds via Otsu:
* - tChange: optimal ΔVV threshold (bimodal histogram: changed vs unchanged)
* - tAbs: optimal VV_post threshold (bimodal histogram: dark vs bright)
* 3) Transform each evidence via sigmoid:
* - p_change = sigmoid((tChange - ΔVV) / k_change)
* - p_abs = sigmoid((tAbs - VV_post) / k_abs)
* - p_ratio = sigmoid((ratioThreshDb - VH/VV) / k_ratio)
* Result: continuous 0-1 probability (0=unlikely, 1=certain)
* 4) Weighted ensemble:
* - floodProb = w_change*p_change + w_abs*p_abs + w_ratio*p_ratio
* - Weights from CONFIG.SCORE (sum to ~1.0)
*
* WHY ENSEMBLE (vs single threshold):
* - Single evidence can fail:
* * ΔVV alone: misses floods over already-dark surfaces (wetlands, rice paddies)
* * VV_post alone: false positives on smooth surfaces (parking lots, calm lakes)
* * VH/VV alone: noisy in vegetation, unstable in urban areas
* - Ensemble is robust: multiple lines of evidence must agree
* - Weighted combination: emphasize most reliable evidence for each AOI type
*
* WHY SIGMOID (vs hard threshold):
* - Hard threshold: if (ΔVV < tChange) → 1, else → 0
* * Brittle: single dB difference changes decision completely
* * Noise-sensitive: speckle causes salt-and-pepper artifacts
* - Soft sigmoid: probability gradually transitions around threshold
* * Robust: small deviations → small probability changes
* * Interpretable: probability = confidence in flood presence
* * Tunable: k parameter controls transition sharpness
*
* HISTOGRAM MASKING (AOI shape robustness):
* - Problem: Otsu thresholds depend on histogram of AOI
* * DRAWN bbox: includes ocean → ocean's dark VV dominates histogram
* * GAUL polygon: excludes ocean → histogram represents land
* * Result: different thresholds → inconsistent flood extent
* - Solution: Use dedicated histMask for threshold estimation only
* * histMask = land-like (DEM footprint) + not-permanent-water (GSW)
* * Estimate tChange, tAbs from masked histogram (stable across AOI shapes)
* * Apply probabilities to full unmasked data (no data loss)
* - Implementation:
* * Create temporary masked copies: deltaVV_hist, postVV_hist
* * Use these ONLY for safeHistogram() → otsu()
* * Compute probabilities on original unmasked deltaVV, postVV
*
* EXAMPLE THRESHOLDS (typical values):
* - tChange (ΔVV threshold):
* * -3 to -5 dB: Significant darkening (flood over land)
* * More negative (e.g., -7 dB): Strong flood signal (good separation)
* * Less negative (e.g., -2 dB): Weak signal (may include soil moisture changes)
* - tAbs (VV_post threshold):
* * -15 to -18 dB: Typical water/dark surface cutoff
* * More negative (e.g., -20 dB): Very strict (only open water)
* * Less negative (e.g., -12 dB): Looser (includes wet soil, urban shadow)
*
* EXAMPLE PROBABILITIES:
* Scenario: tChange = -4 dB, k_change = 1.5 dB
* - Pixel A: ΔVV = -7 dB (3 dB below threshold) → sigmoid(2) ≈ 0.88 → HIGH flood probability
* - Pixel B: ΔVV = -4 dB (at threshold) → sigmoid(0) = 0.50 → MEDIUM flood probability
* - Pixel C: ΔVV = -1 dB (3 dB above threshold) → sigmoid(-2) ≈ 0.12 → LOW flood probability
*
* OUTPUTS:
* Multiband image with:
* - floodProb: Final ensemble probability (0-1)
* - deltaVV: Change in VV (dB)
* - postVV: Post-event VV (dB)
* - ratioDb: VH/VV ratio (dB)
* - p_change, p_abs, p_ratio: Component probabilities (0-1, for diagnostics)
*
* @param {ee.Image} pre - Pre-event composite with VV, VH (dB)
* @param {ee.Image} post - Post-event composite with VV, VH (dB)
* @param {ee.Geometry} aoi - Area of interest
* @param {ee.Image=} histMask - OPTIONAL mask for Otsu histogram sampling (land + not-perm-water)
* @return {ee.Image} Multiband image with floodProb and diagnostic bands
*/
function floodProbability(pre, post, aoi, histMask) {
var preVV = pre.select('VV');
var postVV = post.select('VV');
var postVH = post.select('VH');
var deltaVV = postVV.subtract(preVV).rename('deltaVV');
var ratioDb = postVH.subtract(postVV).rename('ratioDb');
// ------------------------------------------------------------
// Histogram sampling (Otsu) — OPTIONAL stabilization
// ------------------------------------------------------------
// IMPORTANT:
// - Using the AOI directly can produce different thresholds for GAUL vs DRAWN bbox,
// especially near coasts (ocean dominates VV histogram).
// - We therefore allow passing a dedicated histMask (land-like & not-perm-water).
//
// We do NOT want to permanently mask deltaVV/postVV for probability computation,
// so use dedicated masked copies ONLY for histogram estimation.
var deltaVV_hist = deltaVV;
var postVV_hist = postVV;
if (typeof histMask !== 'undefined' && histMask !== null) {
deltaVV_hist = deltaVV.updateMask(histMask);
postVV_hist = postVV.updateMask(histMask);
}
// Histograms (safe)
var deltaHist = safeHistogram(deltaVV_hist, 'deltaVV', aoi, 30, 255);
var postHist = safeHistogram(postVV_hist, 'VV', aoi, 30, 255);
// Otsu thresholds (scalar numbers)
var tChange = otsu(deltaHist);
var tAbs = otsu(postHist);
print('Otsu thresholds: tChange (deltaVV) =', tChange, 'tAbs (postVV) =', tAbs);
// Promote scalars to images to keep image-space math (prevents ee.Number → rename errors)
var tChangeImg = ee.Image.constant(tChange);
var tAbsImg = ee.Image.constant(tAbs);
// Components (soft 0..1)
var p_change = sigmoid(tChangeImg.subtract(deltaVV).divide(CONFIG.SCORE.k_change)).rename('p_change');
var p_abs = sigmoid(tAbsImg.subtract(postVV).divide(CONFIG.SCORE.k_abs)).rename('p_abs');
var p_ratio = sigmoid(
ee.Image.constant(CONFIG.SCORE.ratioThreshDb).subtract(ratioDb).divide(CONFIG.SCORE.k_ratio)
).rename('p_ratio');
// Weighted ensemble probability
var floodProb = p_change.multiply(CONFIG.SCORE.w_change)
.add(p_abs.multiply(CONFIG.SCORE.w_abs))
.add(p_ratio.multiply(CONFIG.SCORE.w_ratio))
.clamp(0, 1)
.rename('floodProb');
return ee.Image.cat([
floodProb,
deltaVV,
postVV.rename('postVV'),
ratioDb,
p_change, p_abs, p_ratio
]);
}
/**
* Load a coherence asset (external SLC coherence GeoTIFF uploaded to Assets).
*
* PURPOSE:
* - Access pre-computed Sentinel-1 SLC interferometric coherence
* - Coherence computed externally (SNAP, HyP3, ISCE2) and uploaded to GEE
*
* COHERENCE BACKGROUND:
* - Coherence = phase correlation between two SAR acquisitions
* - Range: 0 (completely decorrelated) to 1 (perfectly coherent)
* - High coherence (>0.7): Stable surface (bare soil, rock, urban)
* - Low coherence (<0.3): Surface changed (water, vegetation growth, landslide)
*
* TYPICAL BAND NAMES:
* - SNAP: 'coh' or 'coherence'
* - ASF HyP3: 'corr' or 'b1' (single-band GeoTIFF)
* - Custom processing: varies (check metadata)
* - CONFIG.COH.cohBand controls which band to select (default 'b1')
*
* EXPECTED FORMAT:
* - Single-band GeoTIFF
* - Values: 0-1 (float) or 0-255 (byte, needs rescaling)
* - Projection: same as Sentinel-1 (typically EPSG:4326 or UTM)
* - Resolution: 10-20m (depends on processing)
*
* USAGE IN THIS SCRIPT:
* - Event coherence: computed from SLC pair during flood
* - Pre-event coherence (optional): computed from SLC pair before flood
* - Used by coherenceEvidence() to compute coherence-based flood probability
*
* @param {String} assetId - GEE Asset path (e.g., 'users/yourname/flood_coherence')
* @param {ee.Geometry} aoi - Area of interest (for clipping)
* @return {ee.Image} Single-band image named 'coh' (values 0-1)
*/
function loadCoherence(assetId, aoi) {
return ee.Image(assetId).select([CONFIG.COH.cohBand]).rename('coh').clip(aoi);
}
/**
* Build coherence evidence probability for flood detection.
*
* PURPOSE:
* - Compute flood probability from Sentinel-1 SLC coherence evidence
* - Combine two coherence-based signals: low coherence + coherence drop
* - Complement GRD backscatter evidence (detects surface change, not just darkness)
*
* ALGORITHM:
* 1) Low coherence evidence:
* - Event coherence < lowCohThresh (e.g., 0.25) → likely surface change
* - p_coh_low = sigmoid((lowCohThresh - coh_event) / k_low)
* - Example: coh_event = 0.15 → p_coh_low ≈ 0.84 (high probability)
*
* 2) Coherence drop evidence (if pre-event coherence available):
* - Δcoh = coh_pre - coh_event (how much coherence dropped)
* - Δcoh > cohDropThresh (e.g., 0.15) → likely NEW surface change
* - p_coh_drop = sigmoid((Δcoh - cohDropThresh) / k_drop)
* - Example: coh_pre = 0.60, coh_event = 0.20 → Δcoh = 0.40 → p_coh_drop ≈ 0.99
*
* 3) Combine evidences (if both available):
* - OR-logic: cohProb = 1 - (1 - p_low) * (1 - p_drop)
* - Interpretation: flood if EITHER low coherence OR coherence drop
* - If only event coherence: cohProb = p_low
*
* WHY COHERENCE HELPS:
* - GRD detects "dark surfaces" → can't distinguish water from smooth manmade surfaces
* - Coherence detects "surface change" → distinguishes flood from pre-existing dark surfaces
* - Example: Parking lot
* * GRD: Very dark VV → FALSE POSITIVE flood
* * Coherence: Still high (0.8) → NOT decorrelated → NO flood
* * Result: Fusion reduces false positive
*
* WHY COHERENCE DROP (pre vs event):
* - Problem: Some surfaces naturally have low coherence (vegetation, water bodies)
* - Low event coherence alone → can't distinguish flood from pre-existing low coherence
* - Solution: Compare to pre-event coherence
* * coh_pre high, coh_event low → NEW decorrelation → flood
* * coh_pre low, coh_event low → PERSISTENT low coherence → not flood (e.g., forest)
*
* OR-COMBINE LOGIC:
* - Why OR (not AND): Flood can cause either low coherence OR coherence drop
* * Scenario A: Flood over bare soil → coh_pre = 0.8, coh_event = 0.2 → drop = 0.6 (DETECTED)
* * Scenario B: Flood over wetland → coh_pre = 0.3, coh_event = 0.15 → drop = 0.15 (MARGINAL)
* * But event coherence is low (0.15) → low coherence evidence DETECTED
* - Formula: P(A OR B) = 1 - P(not A) * P(not B) = 1 - (1-p_low)*(1-p_drop)
*
* WHEN COHERENCE IS DISABLED (CONFIG.COH.enabled = false):
* - Return masked image with band 'cohProb'
* - updateMask(0) makes it fully transparent
* - fuseFloodProbability() ignores it (no contribution to final probability)
* - Prevents crashes when coherence assets not available
*
* CHECKING FOR PRE-COHERENCE ASSET:
* - hasPre checks if assetId string starts with 'users/' or 'projects/'
* - If empty string '' or invalid: skip coherence drop computation
* - Return zero images for cohPre, dCoh, p_drop (won't contribute to cohProb)
*
* OUTPUTS:
* Multiband image with:
* - cohProb: Final coherence probability (0-1)
* - coh_event: Event coherence (0-1)
* - coh_pre: Pre-event coherence (0-1, or 0 if not available)
* - dCoh: Coherence drop (pre - event, or 0 if pre not available)
* - p_coh_low: Low coherence probability (0-1)
* - p_coh_drop: Coherence drop probability (0-1, or 0 if pre not available)
*
* @param {ee.Geometry} aoi - Area of interest
* @return {ee.Image} Multiband image with cohProb and diagnostic bands
*/
function coherenceEvidence(aoi) {
// Early return if coherence disabled: return masked dummy band
if (!CONFIG.COH.enabled) {
// Return masked band so selections won't crash but contributes nothing
return ee.Image(0).rename('cohProb').clip(aoi).updateMask(ee.Image(0));
}
// Load event coherence (REQUIRED if enabled)
var cohEvent = loadCoherence(CONFIG.COH.eventCohAssetId, aoi).rename('coh_event');
// Compute low coherence evidence
// sigmoid: higher probability when coh_event < lowCohThresh
var p_low = sigmoid(
ee.Image.constant(CONFIG.COH.lowCohThresh).subtract(cohEvent).divide(CONFIG.COH.k_low)
).rename('p_coh_low');
// Check if pre-event coherence asset exists (enables coherence drop)
// Valid asset IDs start with 'users/' or 'projects/'
var hasPre = (CONFIG.COH.preCohAssetId &&
(CONFIG.COH.preCohAssetId.indexOf('users/') === 0 ||
CONFIG.COH.preCohAssetId.indexOf('projects/') === 0));
// Initialize pre-coherence bands (will stay zero if pre not available)
var cohPre = ee.Image(0).rename('coh_pre').clip(aoi);
var dCoh = ee.Image(0).rename('dCoh').clip(aoi);
var p_drop = ee.Image(0).rename('p_coh_drop').clip(aoi);
// Compute coherence drop evidence if pre-coherence available
if (hasPre) {
cohPre = loadCoherence(CONFIG.COH.preCohAssetId, aoi).rename('coh_pre');
dCoh = cohPre.subtract(cohEvent).rename('dCoh'); // Positive = coherence decreased
// sigmoid: higher probability when Δcoh > cohDropThresh
p_drop = sigmoid(
dCoh.subtract(ee.Image.constant(CONFIG.COH.cohDropThresh)).divide(CONFIG.COH.k_drop)
).rename('p_coh_drop');
}
// Combine evidences: OR-logic (flood if EITHER low coherence OR coherence drop)
// Formula: P(A OR B) = 1 - (1-P(A))*(1-P(B))
var cohProb = hasPre
? ee.Image(1).subtract(
ee.Image(1).subtract(p_low).multiply(ee.Image(1).subtract(p_drop))
).rename('cohProb')
: p_low.rename('cohProb'); // If no pre-coherence, use only low coherence evidence
// Return all bands for diagnostics and fusion
return ee.Image.cat([cohEvent, cohPre, dCoh, p_low, p_drop, cohProb]).clip(aoi);
}
/**
* Fuse GRD flood probability with coherence probability (weighted linear blend).
*
* PURPOSE:
* - Combine two independent flood evidence sources into single probability
* - GRD (dominant): backscatter-based detection (dark water)
* - Coherence (supporting): phase-based detection (surface change)
*
* FUSION STRATEGY:
* - Linear weighted average: fused = w_grd * P_grd + w_coh * P_coh
* - Default weights: w_grd = 0.75, w_coh = 0.25 (GRD dominates)
* - Optional clamping: ensure result stays in [0,1] range
*
* WHY LINEAR BLEND (not product or max):
* - Product (P_grd * P_coh): Too conservative, misses floods where one signal is weak
* * Example: Flood under vegetation → GRD = 0.6, Coherence = 0.9 → Product = 0.54 (too low)
* - Max (max(P_grd, P_coh)): Too liberal, allows false positives from single source
* * Example: Smooth urban → GRD = 0.8, Coherence = 0.1 → Max = 0.8 (false positive)
* - Linear blend: Balanced, coherence slightly boosts/suppresses GRD probability
* * Example: Flood evidence → GRD = 0.7, Coherence = 0.8 → Fused = 0.725 (modest boost)
* * Example: False positive → GRD = 0.7, Coherence = 0.2 → Fused = 0.575 (suppressed)
*
* WHEN COHERENCE DISABLED (CONFIG.COH.enabled = false):
* - Return GRD probability unchanged
* - No fusion computation performed
* - cohProb band is ignored (masked, contributes nothing)
*
* FUSION WEIGHTS TUNING:
* - More GRD weight (0.8-0.9): When GRD is highly reliable (open terrain, good acquisitions)
* - More coherence weight (0.3-0.4): When urban flood or coherence very reliable
* - Equal weights (0.5-0.5): When both equally reliable (rare)
* - IMPORTANT: w_grd + w_coh should = 1.0 for interpretable probabilities
*
* CLAMPING (CONFIG.COH.FUSE.clamp01):
* - Why needed: Weighted sum can theoretically exceed [0,1] if weights don't sum to 1.0
* - Safety measure: clamp(0,1) ensures valid probability range
* - Recommended: keep enabled (clamp01 = true)
*
* EXAMPLE FUSION:
* Config: w_grd = 0.75, w_coh = 0.25
* - Scenario A (strong flood):
* * GRD = 0.85, Coherence = 0.90 → Fused = 0.75*0.85 + 0.25*0.90 = 0.8625 (high confidence)
* - Scenario B (weak flood):
* * GRD = 0.40, Coherence = 0.50 → Fused = 0.75*0.40 + 0.25*0.50 = 0.425 (low confidence)
* - Scenario C (false positive suppression):
* * GRD = 0.70, Coherence = 0.20 → Fused = 0.75*0.70 + 0.25*0.20 = 0.575 (reduced from 0.70)
*
* @param {ee.Image} grdProb - GRD-based flood probability (single band 'floodProb', 0-1)
* @param {ee.Image} cohProb - Coherence-based flood probability (single band 'cohProb', 0-1, masked if disabled)
* @return {ee.Image} Fused flood probability (single band 'floodProb', 0-1)
*/
function fuseFloodProbability(grdProb, cohProb) {
// If coherence disabled, return GRD probability unchanged
if (!CONFIG.COH.enabled) return grdProb.rename('floodProb');
// Read fusion weights from config
var wG = CONFIG.COH.FUSE.w_grd;
var wC = CONFIG.COH.FUSE.w_coh;
// Linear weighted blend
var fused = grdProb.multiply(wG).add(cohProb.multiply(wC));
// Optional clamping to [0,1] (safety against weight misconfiguration)
if (CONFIG.COH.FUSE.clamp01) fused = fused.clamp(0, 1);
return fused.rename('floodProb');
}
/**
* Apply plausibility masks and post-processing to flood probability.
*
* PURPOSE:
* - Remove physically implausible flood detections (steep slopes, high terrain, permanent water)
* - Convert continuous probability to binary flood mask
* - Clean up result (remove speckle, smooth edges)
*
* WORKFLOW:
* 1) Load auxiliary datasets:
* - Copernicus DEM GLO-30: elevation, slope
* - MERIT Hydro: HAND (Height Above Nearest Drainage)
* - JRC Global Surface Water: permanent water occurrence
* 2) Create plausibility masks:
* - Slope mask: keep pixels with slope <= maxSlopeDeg (e.g., 5°)
* - HAND mask: keep pixels with HAND <= maxHANDm (e.g., 40m)
* - Permanent water mask: remove pixels with GSW occurrence >= permWaterOcc (e.g., 85%)
* - DEM footprint mask: remove ocean/no-data areas
* 3) Apply masks to flood probability:
* - Successive updateMask() calls (AND logic: must pass all tests)
* - Result: floodProbMasked (probability masked to plausible flood zones)
* 4) Threshold to binary:
* - flood = floodProbMasked >= floodProbThreshold (e.g., 0.35)
* 5) Post-processing:
* - Remove small connected components (< minConnectedPixels)
* - Optional morphological smoothing (dilation → erosion)
* 6) Return diagnostic bands:
* - flood: binary flood mask
* - floodProbMasked: masked probability
* - slope, hand, permWater: plausibility layers for inspection
*
* SLOPE MASKING (physics constraint):
* - Why: Steep terrain prone to radar shadow/layover artifacts
* - Threshold: maxSlopeDeg (default 5°)
* * Flat plains (<3°): Can use stricter threshold
* * Mountainous valleys (5-10°): Need looser threshold to keep riverbank flooding
* - How: ee.Terrain.products(dem) computes slope at ~30m resolution
* - Edge case handling:
* * slopeForMask.unmask(999): DEM voids → fail slope test (conservative)
* * slope display: .unmask(0) → show as 0° in Pixel Inspector (cleaner)
*
* HAND MASKING (hydrological constraint):
* - What: Height Above Nearest Drainage (vertical distance to river)
* - Why: Flood water flows to low areas near rivers
* - Threshold: maxHANDm (default 40m)
* * Riverine flood: 10-20m (strict, floodplain-focused)
* * Coastal/pluvial flood: 40-80m (loose, includes inland depressions)
* - Dataset: MERIT Hydro 'hnd' band (90m resolution, global)
* - How: hand.lte(maxHANDm) creates mask (1=keep, 0=reject)
*
* PERMANENT WATER MASKING (baseline exclusion):
* - What: JRC Global Surface Water occurrence (% of time pixel was water, 1984-2021)
* - Why: Exclude lakes/reservoirs/rivers (not "flood" but pre-existing water)
* - Threshold: permWaterOcc (default 85%)
* * High (95%): Mask only very permanent water (keeps seasonal wetlands)
* * Low (70-80%): Mask seasonal water too (may remove flood in rice paddies)
* - How: permWater.not() inverts mask (1=not permanent, 0=permanent)
*
* DEM PROJECTION HANDLING:
* - setDefaultProjection(dem.projection(), null, 30):
* * Forces ~30m scale for terrain derivatives (prevents coarse pyramid computation)
* * Lighter than reproject() for large AOIs
* * Prevents "slope computed at 500m" artifacts
*
* POST-PROCESSING:
* 1) Thresholding:
* - floodProbMasked >= floodProbThreshold → binary (1=flood, 0=no flood)
* 2) Connected components:
* - connectedPixelCount(100, true): count pixels in each blob (max 100)
* - Keep blobs with >= minConnectedPixels (e.g., 5)
* - Removes salt-and-pepper speckle noise
* 3) Morphological smoothing (if morphRadiusPx > 0):
* - focal_max (dilation): expands edges, fills small holes
* - focal_min (erosion): shrinks back to original size, smooths boundaries
* - Result: cleaner edges, merged nearby water bodies
* - Trade-off: Slightly inflates area, can merge distinct flood patches
*
* OUTPUTS:
* Multiband image with:
* - flood: Binary flood mask (1=flood, 0=no flood)
* - floodProbMasked: Continuous probability after masking (0-1)
* - slope: Slope in degrees (0-90, display layer)
* - hand: HAND in meters (0-1000+, display layer)
* - permWater: Permanent water binary (1=permanent, 0=not permanent)
*
* @param {ee.Image} floodProbImg - Continuous flood probability (0-1, single band)
* @param {ee.Geometry} aoi - Area of interest
* @return {ee.Image} Multiband image with flood mask and diagnostic layers
*/
function applyMasksAndPost(floodProbImg, aoi) {
// Load and prepare Copernicus DEM
var demIC = ee.ImageCollection('COPERNICUS/DEM/GLO30').filterBounds(aoi);
print('DEM tiles in AOI:', demIC.size());
// Mosaic tiles and force consistent 30m projection for terrain derivatives
// Why: Prevents slope computation at coarse pyramid levels (e.g., 500m)
var dem = demIC.select('DEM').mosaic().rename('elev').clip(aoi);
// NOTE: setDefaultProjection is lighter than reproject for large AOIs
dem = dem.setDefaultProjection(dem.projection(), null, 30);
// Extract DEM footprint mask (valid data over land, masked over ocean)
var landMask = dem.mask();
// Slope (keep float, computed at ~30m)
// Terrain.products computes slope, aspect, hillshade in one call (more stable than Terrain.slope)
var slopeRaw = ee.Terrain.products(dem).select('slope').rename('slope').toFloat();
// Two versions of slope image:
// For masking: DEM voids → 999° (guaranteed to fail slope test, conservative approach)
var slopeForMask = slopeRaw.unmask(999);
// For display: DEM voids → 0° (cleaner visualization in Pixel Inspector)
var slope = slopeRaw.updateMask(landMask).unmask(0).toFloat();
// Create slope plausibility mask: keep only pixels with slope <= maxSlopeDeg
// unmask(1) ensures masked areas (outside DEM footprint) don't fail the test
var maskSlope = slopeForMask.lte(CONFIG.MASKS.maxSlopeDeg).unmask(1);
// Load HAND (Height Above Nearest Drainage)
// MERIT Hydro HAND: vertical distance to nearest river (m)
// Low HAND (e.g., <40m) = near rivers = likely to flood
var hand = ee.Image('MERIT/Hydro/v1_0_1')
.select('hnd').clip(aoi).rename('hand')
.unmask(0); // Fill no-data with 0m (conservative: allows flood detection)
// Create HAND plausibility mask: keep only pixels near drainage (HAND <= maxHANDm)
var maskHand = hand.lte(CONFIG.MASKS.maxHANDm).unmask(1);
// Load permanent water mask (JRC Global Surface Water)
// GSW 'occurrence': % of time pixel was water during 1984-2021 (0-100%)
var gsw = ee.Image('JRC/GSW1_4/GlobalSurfaceWater')
.select('occurrence').clip(aoi);
// Identify permanent water: occurrence >= permWaterOcc threshold (e.g., 85%)
// Result: 1 = permanent water (lake/reservoir), 0 = not permanent water
var permWater = gsw.gte(CONFIG.MASKS.permWaterOcc).rename('permWater');
// Invert mask: we want to KEEP non-permanent water areas (potential flood zones)
// not() flips: 1 (permanent) → 0 (mask out), 0 (not permanent) → 1 (keep)
var maskNotPermWater = permWater.not().unmask(1);
// Apply all plausibility masks to flood probability
// Successive updateMask() calls implement AND logic:
// - Must pass DEM footprint test (not ocean)
// - Must pass slope test (not too steep)
// - Must pass HAND test (near drainage network)
// - Must pass permanent water test (not pre-existing water body)
// Any pixel failing any test becomes masked (transparent)
var floodProbMasked = floodProbImg.rename('floodProb')
.updateMask(landMask) // Remove ocean/no-data
.updateMask(maskSlope) // Remove steep terrain
.updateMask(maskHand) // Remove high terrain (far from rivers)
.updateMask(maskNotPermWater); // Remove permanent water bodies
// Threshold probability to binary flood mask
// Convert continuous probability (0-1) to binary (0 or 1)
// Pixels with probability >= threshold become 1 (flood), others become 0 (no flood)
var flood = floodProbMasked.gte(CONFIG.POST.floodProbThreshold).rename('flood');
// Remove small connected components (speckle noise)
// Count pixels in each connected flood blob (max count = 100 to limit computation)
// eightConnected = true: diagonal neighbors count as connected
var blobs = flood.connectedPixelCount(100, true);
// Keep only blobs with >= minConnectedPixels (e.g., 5 pixels)
// Removes isolated 1-4 pixel speckles caused by noise
flood = flood.updateMask(blobs.gte(CONFIG.POST.minConnectedPixels));
// OPTIONAL morphological smoothing
if (CONFIG.POST.morphRadiusPx > 0) {
// Create circular kernel with specified radius (pixels)
var k = ee.Kernel.circle(CONFIG.POST.morphRadiusPx);
// Dilation (focal_max): expand flood edges, fill small interior holes
// Erosion (focal_min): shrink back to approximately original size
// Net effect: smoother boundaries, merged nearby patches, filled holes
// Trade-off: Slightly inflates area (typically 1-2 pixels per edge)
flood = flood.focal_max({kernel: k, iterations: 1}) // Expand
.focal_min({kernel: k, iterations: 1}); // Contract
}
// Self-mask and return diagnostic bands
// selfMask(): convert binary (0 or 1) to masked (0 → masked, 1 → unmasked)
// Result: only flood pixels (value = 1) are visible; non-flood is transparent
flood = flood.selfMask();
return ee.Image.cat([
flood.rename('flood'), // Binary flood extent (1 = flood)
floodProbMasked.rename('floodProbMasked'), // Masked probability (0-1)
slope, // Slope in degrees (diagnostic)
hand, // HAND in meters (diagnostic)
permWater // Permanent water binary (diagnostic)
]);
}
/** #############################################
* 4) Rain + atmospheric cross-check
* #############################################
*
* OVERVIEW:
* - Cross-validate SAR-detected flood with independent precipitation data
* - Verify flood plausibility (was there actually heavy rain?)
* - Identify meteorological drivers (tropical cyclones, frontal systems)
* - Temporal correlation: did precipitation peak align with flood timing?
*
* DATASETS:
* 1) IMERG (NASA GPM): Satellite-based precipitation estimates
* - Resolution: 0.1° (~11 km), 30-minute temporal
* - Best for: Tropical/subtropical events, detailed timing
* 2) ECMWF IFS: Numerical weather model diagnostics
* - Resolution: ~9 km, hourly
* - Best for: Synoptic patterns, atmospheric circulation
*
* WORKFLOW:
* 1) imergAccumMm(): Total rainfall accumulation over event window
* 2) ifsDiagnostics(): Mean atmospheric diagnostics (precip, vorticity, wind)
* 3) imergSeriesWithCumulative(): Time series for temporal analysis
*
* WHY CROSS-CHECK:
* - SAR can detect dark surfaces that aren't flood (smooth urban, shadow)
* - Strong precipitation + SAR detection → HIGH confidence flood
* - No precipitation + SAR detection → CHECK: dam release? tide? false positive?
*
* INTERPRETATION:
* - 100-300mm IMERG + SAR flood → HIGH confidence (pluvial/riverine flood)
* - <20mm IMERG + SAR flood → INVESTIGATE (tidal? dam release? misdetection?)
* - Strong vorticity (850 hPa) → tropical cyclone or monsoon depression
* - High wind speed → wind roughening may affect VV backscatter
*/
/**
* Compute total IMERG precipitation accumulation over event window.
*
* PURPOSE:
* - Calculate total rainfall (mm) during suspected flood period
* - Primary cross-check for SAR-detected flood plausibility
* - Answers: "Was there enough rain to cause flooding?"
*
* DATASET:
* - IMERG V07 (Integrated Multi-satellitE Retrievals for GPM)
* - Source: NASA Global Precipitation Measurement (GPM) mission
* - Resolution: 0.1° spatial (~11 km), 30-minute temporal
* - Band: 'precipitation' (mm/hr instantaneous rate)
* - Coverage: Global 60°N-60°S (no polar regions)
*
* ALGORITHM:
* 1) Filter IMERG images to AOI and time window
* 2) Convert rate (mm/hr) to increment per timestep:
* - IMERG V07 timestep: 30 minutes = 0.5 hours
* - Increment (mm) = rate (mm/hr) × 0.5 hr
* 3) Sum all increments to get total accumulation (mm)
*
* EXAMPLE CALCULATION:
* Event window: Nov 22-28, 2025 (6 days)
* Timesteps: 6 days × 48 images/day = 288 images
*
* Image 1: rate = 2 mm/hr → increment = 1 mm
* Image 2: rate = 8 mm/hr → increment = 4 mm
* ...
* Image 288: rate = 0 mm/hr → increment = 0 mm
*
* Total accumulation = sum of all increments = 150 mm
*
* INTERPRETATION GUIDANCE:
* - <20 mm: Light rain, unlikely to cause flooding (check for other causes)
* - 20-50 mm: Moderate rain, possible localized flooding (urban, poor drainage)
* - 50-100 mm: Heavy rain, likely flooding in vulnerable areas
* - 100-300 mm: Very heavy rain, widespread flooding expected
* - >300 mm: Extreme rain, catastrophic flooding likely
*
* LIMITATIONS:
* - Lower accuracy over mountains (orographic enhancement underestimated)
* - Lower accuracy over complex coastlines (land/sea mixing)
* - Represents spatial average (~11 km), misses sub-grid convective cells
* - Satellite-based estimates (not rain gauge measurements)
*
* TYPICAL VALUES:
* - Tropical cyclone: 200-500 mm over 3-5 days
* - Monsoon heavy rain: 100-300 mm over 2-3 days
* - Frontal system: 50-150 mm over 1-2 days
* - Convective storms: 30-80 mm over hours
*
* OUTPUT:
* - Single-band image: 'imerg_mm' (total accumulation in millimeters)
* - Clipped to AOI for visualization and export
*
* @param {ee.Geometry} aoi - Area of interest
* @param {ee.Date} start - Event window start
* @param {ee.Date} end - Event window end
* @return {ee.Image} Single-band image with total precipitation (mm)
*/
function imergAccumMm(aoi, start, end) {
// Filter IMERG collection to AOI and time window
var ic = ee.ImageCollection(CONFIG.MET.imergCollection)
.filterBounds(aoi)
.filterDate(start, end)
.select(CONFIG.MET.imergBand); // 'precipitation' band (mm/hr)
// Convert each image from rate (mm/hr) to increment (mm per 30-min timestep)
// IMERG V07 has 30-minute timesteps (0.5 hours)
// Increment (mm) = rate (mm/hr) × 0.5 hr
return ic.map(function(img) {
return img.multiply(0.5); // mm/hr × 0.5 hr = mm per timestep
})
.sum() // Sum all increments to get total accumulation
.rename('imerg_mm')
.clip(aoi);
}
/**
* Compute mean ECMWF IFS diagnostics over event window.
*
* PURPOSE:
* - Extract synoptic-scale atmospheric diagnostics for flood context
* - Identify meteorological drivers (cyclones, fronts, wind patterns)
* - Complement IMERG with model-based precipitation and circulation info
*
* DATASET:
* - ECMWF IFS (Integrated Forecasting System) NRT operational forecast
* - Source: European Centre for Medium-Range Weather Forecasts
* - Resolution: ~9 km horizontal, hourly temporal
* - Coverage: Global
* - Data type: Numerical weather model output (not observations)
*
* DIAGNOSTICS COMPUTED:
* 1) Precipitation rate:
* - Band: 'total_precipitation_rate_sfc' (kg m^-2 s^-1 at surface)
* - Conversion: kg m^-2 s^-1 × 3600 s/hr = mm/hr (since 1 kg/m² = 1 mm water depth)
* - Mean over event window → typical precip rate during event
*
* 2) Vorticity at 850 hPa:
* - Band: 'vorticity_pl850' (s^-1 at 850 hPa pressure level, ~1.5 km altitude)
* - Physical meaning: Rotation of horizontal wind (cyclonic spin)
* - Positive (Northern Hemisphere) = counterclockwise rotation = low pressure system
* - Strong positive vorticity → tropical cyclone, monsoon depression, or frontal system
*
* 3) 10-meter wind speed:
* - Bands: 'u_component_of_wind_10m_sfc' (eastward, m/s)
* 'v_component_of_wind_10m_sfc' (northward, m/s)
* - Computation: speed = sqrt(u² + v²) [vector magnitude]
* - High wind → storm system, can roughen water surface (affects SAR backscatter)
*
* WHY THESE DIAGNOSTICS:
* - Precipitation rate: Model-based cross-check for IMERG (independent estimate)
* - Vorticity: Identifies organized storm systems (cyclones vs random convection)
* - Wind speed: Helps interpret SAR backscatter (wind roughening can darken water)
*
* MEAN vs SUM:
* - Uses .mean() over time window (not .sum())
* - Result: "typical" or "average" condition during event
* - Rationale: Identifies persistent features (e.g., cyclone present throughout window)
* - For total precipitation, use imergAccumMm() instead (sum-based)
*
* INTERPRETATION:
* Precipitation rate (ifs_prate_mmhr):
* - <2 mm/hr: Light/scattered rain
* - 2-5 mm/hr: Moderate continuous rain
* - 5-10 mm/hr: Heavy continuous rain (likely flooding)
* - >10 mm/hr: Extreme rain (catastrophic flooding)
*
* Vorticity (ifs_vort850, Northern Hemisphere):
* - Negative: Anticyclonic (high pressure, generally dry)
* - 0 to +5×10^-5 s^-1: Weak circulation
* - +5×10^-5 to +1×10^-4 s^-1: Moderate low pressure system
* - >+1×10^-4 s^-1: Strong cyclone (tropical cyclone, deep low)
*
* Wind speed (ifs_wspd10):
* - <5 m/s: Calm to light winds
* - 5-10 m/s: Moderate winds
* - 10-20 m/s: Strong winds (tropical storm force)
* - >20 m/s: Very strong winds (hurricane force)
*
* LIMITATIONS:
* - Forecast model output (not direct observations)
* - May have biases (overestimate/underestimate precipitation)
* - Coarser effective resolution than stated (~30-50 km for convection)
* - Best for large-scale features, less reliable for small-scale convection
*
* OUTPUTS:
* Multiband image with:
* - ifs_prate_mmhr: Mean precipitation rate (mm/hr)
* - ifs_vort850: Mean vorticity at 850 hPa (s^-1)
* - ifs_wspd10: Mean 10m wind speed (m/s)
*
* @param {ee.Geometry} aoi - Area of interest
* @param {ee.Date} start - Event window start
* @param {ee.Date} end - Event window end
* @return {ee.Image} Multiband image with atmospheric diagnostics
*/
function ifsDiagnostics(aoi, start, end) {
// Filter IFS collection to AOI and time window
var ic = ee.ImageCollection(CONFIG.MET.ifsCollection)
.filterBounds(aoi)
.filterDate(start, end)
.select(CONFIG.MET.ifsBands);
// Precipitation rate: convert kg m^-2 s^-1 to mm/hr
// Physics: 1 kg of water per m² = 1 mm water depth
// Units: kg m^-2 s^-1 × 3600 s/hr = kg m^-2 hr^-1 = mm/hr
var prate = ic.select('total_precipitation_rate_sfc')
.mean() // Mean rate over event window
.multiply(3600) // Convert s^-1 to hr^-1
.rename('ifs_prate_mmhr');
// Vorticity at 850 hPa: mean circulation strength
// Units: s^-1 (per second)
// Positive (NH) = cyclonic = low pressure
var vort850 = ic.select('vorticity_pl850')
.mean()
.rename('ifs_vort850');
// Wind speed at 10m: compute vector magnitude from components
// u = eastward component (m/s), v = northward component (m/s)
// speed = sqrt(u² + v²)
var u10 = ic.select('u_component_of_wind_10m_sfc').mean();
var v10 = ic.select('v_component_of_wind_10m_sfc').mean();
var wspd = u10.hypot(v10).rename('ifs_wspd10'); // hypot = sqrt(u² + v²)
// Return multiband diagnostic image
return ee.Image.cat([prate, vort850, wspd]).clip(aoi);
}
/**
* Build IMERG time series with both instantaneous rate (mm/hr) and cumulative accumulation (mm).
*
* PURPOSE:
* - Track precipitation evolution over time (when did heavy rain start/end?)
* - Compute running total accumulation (how much rain fell by each timestep?)
* - Helps diagnose flood trigger timing and verify SAR-detected flood plausibility
*
* ALGORITHM:
* 1) For each IMERG image (30-min timestep):
* - Extract rate (mm/hr)
* - Compute increment: rate * stepHours (typically 0.5 hr) → mm
* 2) Use ee.List.iterate() to maintain running cumulative sum:
* - Start with cum = 0 mm
* - Each step: cum_new = cum_old + increment
* - Store both rate and cum as bands in output image
* 3) Return ImageCollection with bands: rate_mmhr, accum_mm
*
* WHY ITERATE (not reduce):
* - Need time-stamped cumulative values (not just final total).
* - iterate() carries forward "state" (running sum) across images in temporal order.
*
* OUTPUT USAGE:
* - Chart.image.series() to visualize rate + accumulation on dual Y-axes
* - Identify precipitation peaks → correlate with SAR acquisition timing
*
* EXAMPLE:
* - IMERG image at T0: rate = 5 mm/hr → increment = 2.5 mm → cum = 2.5 mm
* - IMERG image at T1: rate = 10 mm/hr → increment = 5 mm → cum = 7.5 mm
* - IMERG image at T2: rate = 2 mm/hr → increment = 1 mm → cum = 8.5 mm
*
* @param {ee.Geometry} aoi
* @param {ee.Date} start
* @param {ee.Date} end
* @return {ee.ImageCollection} images with bands: rate_mmhr (mm/hr), accum_mm (mm cumulative)
*/
function imergSeriesWithCumulative(aoi, start, end) {
var stepH = CONFIG.MET.imergStepHours || 0.5; // IMERG V07 is 30-min (0.5 hr per image)
// Build filtered collection (sorted by time)
var ic = ee.ImageCollection(CONFIG.MET.imergCollection)
.filterBounds(aoi)
.filterDate(start, end)
.select(CONFIG.MET.imergBand)
.sort('system:time_start');
var list = ic.toList(ic.size());
// Initialize state: cumulative = 0, output list = []
var initState = ee.Dictionary({
cum: ee.Image(0), // running cumulative (mm)
out: ee.List([]) // output images
});
// Iterate: carry forward running sum
var finalState = ee.Dictionary(list.iterate(function(img, state) {
img = ee.Image(img);
state = ee.Dictionary(state);
var cum = ee.Image(state.get('cum')); // Previous cumulative total
var rate = img.rename('rate_mmhr'); // Current rate (mm/hr)
var incr = rate.multiply(stepH); // Increment for this timestep (mm)
var cum2 = cum.add(incr).rename('accum_mm'); // Updated cumulative
// Build output image with both bands
var outImg = ee.Image.cat([rate, cum2])
.copyProperties(img, ['system:time_start']); // Preserve timestamp
// Update state for next iteration
return ee.Dictionary({
cum: cum2,
out: ee.List(state.get('out')).add(outImg)
});
}, initState));
// Convert output list back to ImageCollection
return ee.ImageCollection.fromImages(ee.List(finalState.get('out')));
}
/** #############################################
* 5) Stats + Export
* #############################################
*
* OVERVIEW:
* - Compute flood statistics (area, pixel counts)
* - Export results to Google Drive for offline analysis
* - Generate downloadable GeoTIFF rasters
*
* WORKFLOW:
* 1) printFloodAreaHa(): Calculate and print flood area to Console
* 2) exportToDrive(): Queue export tasks for final outputs
*
* WHY STATISTICS:
* - Quantify flood extent for reporting (hectares, square kilometers)
* - Compare events ("2024 flood was 2x larger than 2020")
* - Validate against ground truth or other datasets
*
* WHY EXPORT:
* - Enable offline analysis in GIS software (ArcGIS, QGIS)
* - Archive results for future reference
* - Share with stakeholders (government agencies, NGOs)
* - Generate publication-quality maps
*
* IMPORTANT:
* - Area calculations use pixel-accurate ee.Image.pixelArea()
* - Exports queue in Tasks tab but don't auto-run (must click "RUN")
* - Large AOIs may take 5-30 minutes to export
*/
/**
* Compute and print flooded area in hectares to Console.
*
* PURPOSE:
* - Quantify total flood extent for reporting and validation
* - Quick area check without needing to export data
* - Compare multiple scenarios (different thresholds, dates)
*
* ALGORITHM:
* 1) Multiply flood mask (binary: 0 or 1) by pixel area (m²)
* - Result: each flood pixel carries its actual area in m²
* - Non-flood pixels (0) contribute 0 m² to sum
* 2) Sum all pixel areas via reduceRegion
* - Result: total flooded area in m²
* 3) Convert m² to hectares (1 hectare = 10,000 m²)
* 4) Print to Console
*
* WHY PIXELAREA():
* - Accounts for map projection distortion (pixels not square in degrees)
* - Gives true ground area regardless of latitude
* - Example: At 45° latitude, 1° longitude ≈ 78 km (not 111 km like at equator)
* - Result: Accurate area calculations globally
*
* WHY HECTARES (not km² or acres):
* - International standard for agricultural/environmental reporting
* - Easier to interpret for medium-scale floods (10-10,000 ha)
* - Conversion: 1 ha = 10,000 m² = 0.01 km² = 2.47 acres
*
* SCALE PARAMETER:
* - Uses CONFIG.EXPORT.scale (default 10m) for computation
* - Finer scale (10m): More accurate but slower
* - Coarser scale (30m): Faster but less accurate
* - Trade-off: 10m vs 30m typically differs by <2% for large floods
*
* TILESCALE:
* - tileScale=4: Processes in smaller tiles (reduces memory per tile)
* - Higher values (8-16): Use if hitting memory limits
* - Lower values (1-2): Faster but more memory-intensive
*
* SAFETY (ee.Algorithms.If):
* - Checks if areaM2 exists (not null) before dividing
* - Prevents crash if flood mask is entirely masked (no valid pixels)
* - Returns 0 ha if no flood detected (clean output)
*
* TYPICAL VALUES:
* - Small urban flood: 10-100 ha (0.1-1 km²)
* - Riverine flood: 100-10,000 ha (1-100 km²)
* - Major basin flood: 10,000-1,000,000 ha (100-10,000 km²)
* - Catastrophic event: >1,000,000 ha (>10,000 km²)
*
* EXAMPLE OUTPUT:
* Console: "Flooded area (ha): 4523.7"
* Interpretation: 4,523.7 hectares = 45.2 km² = 17.5 square miles
*
* LIMITATIONS:
* - Includes only pixels passing all masks (slope, HAND, permanent water)
* - Does not account for flood depth (2D extent only, not volume)
* - Depends on threshold choice (lower threshold → larger area)
*
* @param {ee.Image} floodMask - Binary flood mask (1=flood, 0=no flood), self-masked
* @param {ee.Geometry} aoi - Area of interest (computation boundary)
*/
function printFloodAreaHa(floodMask, aoi) {
// Multiply flood mask by pixel area: each flood pixel now carries its area in m²
// pixelArea() returns actual ground area accounting for projection distortion
var d = floodMask.rename('flood')
.multiply(ee.Image.pixelArea()) // Binary (0 or 1) × area (m²) = 0 or area
.reduceRegion({
reducer: ee.Reducer.sum(), // Sum all pixel areas → total flooded area (m²)
geometry: aoi,
scale: CONFIG.EXPORT.scale, // Pixel scale for computation (m)
maxPixels: CONFIG.EXPORT.maxPixels, // Safety limit (default 1e13)
tileScale: 4 // Process in smaller tiles (reduces memory)
});
// Extract total area in m² (may be null if no flood pixels)
var areaM2 = ee.Number(d.get('flood'));
// Convert m² to hectares: 1 ha = 10,000 m²
// Safety check: if areaM2 is null (no flood), return 0 instead of crashing
var ha = ee.Number(ee.Algorithms.If(areaM2, areaM2.divide(10000), 0));
// Print to Console (server-side evaluation)
print('Flooded area (ha):', ha);
}
/**
* Export image to Google Drive as GeoTIFF (queues task, does not auto-run).
*
* PURPOSE:
* - Save final outputs for offline analysis and sharing
* - Enable GIS integration (ArcGIS, QGIS, other tools)
* - Archive results for future comparison
* - Generate publication-quality maps
*
* WORKFLOW:
* 1) Script calls this function with image, AOI, and name
* 2) If CONFIG.EXPORT.enabled = false: function returns immediately (no export)
* 3) If enabled = true: Export.image.toDrive() queues task (DOES NOT RUN YET)
* 4) User must manually click "RUN" in Tasks tab (orange icon, top-right)
* 5) Task executes (blue → running, green → complete)
* 6) GeoTIFF appears in Google Drive folder
*
* CRITICAL: TASKS DON'T AUTO-RUN
* - Common mistake: "I enabled export but nothing happened"
* - Reason: Tasks queue but wait for manual confirmation
* - Solution: Check Tasks tab (orange) → click "RUN" on each task
* - Why: Prevents accidental exports, gives user control over what to export
*
* EXPORT PARAMETERS:
*
* image:
* - Can be single-band (flood mask) or multiband (diagnostics)
* - Data type preserved: Float32, Byte, Int16, etc.
*
* description:
* - Task name shown in Tasks tab and filename in Drive
* - Example: 'FloodProb_Fused_2025-11-22'
* - Keep short (<50 chars), avoid special characters
*
* folder:
* - Google Drive folder name (created automatically if missing)
* - Path: Google Drive root → [folder] → [exported files]
* - Default: 'GEE_FLOOD'
*
* region:
* - AOI geometry defining export extent
* - Must be ee.Geometry, not FeatureCollection
* - Large regions: may hit 50 MB file limit → increase scale or split AOI
*
* scale:
* - Export pixel size (meters)
* - 10m: Sentinel-1 native resolution (best quality, largest files)
* - 20-30m: Balanced (faster, still useful)
* - 100m: Regional overview (fast, small files)
*
* maxPixels:
* - Maximum pixels allowed per export (GEE hard limit)
* - Default 1e13 usually sufficient
* - If export fails: "Too many pixels" → increase scale or reduce AOI
*
* OUTPUT FORMAT:
* - GeoTIFF (.tif extension added automatically)
* - Projection: Original image projection (typically EPSG:4326 or UTM)
* - Compression: LZW (lossless, reduces file size ~50%)
* - Bands: Preserve original band names and count
* - NoData: Masked pixels encoded as NoData (not 0)
*
* FILE SIZE ESTIMATES:
* Scale | 1000 km² AOI | 10,000 km² AOI
* -------|--------------|----------------
* 10m | ~10 MB | ~100 MB (may hit limit)
* 20m | ~2.5 MB | ~25 MB
* 30m | ~1 MB | ~10 MB
* 100m | ~0.1 MB | ~1 MB
*
* TYPICAL EXPORTS FROM THIS SCRIPT:
* 1) FloodProb_Fused_YYYY-MM-DD.tif: Continuous probability (Float32, 0-1)
* 2) FloodMask_YYYY-MM-DD.tif: Binary extent (Byte, 0 or 1)
* 3) IMERGaccum_YYYY-MM-DD.tif: Precipitation total (Float32, mm)
* 4) IFSdiagnostics_YYYY-MM-DD.tif: Multiband atmospheric data (Float32)
*
* TROUBLESHOOTING:
*
* Problem: "Export timed out"
* - Solution: Increase scale (30 → 100m), reduce AOI size, or increase tileScale
*
* Problem: "Too many pixels"
* - Solution: Increase scale (10 → 20-30m), or manually split AOI into tiles
*
* Problem: "User memory limit exceeded"
* - Solution: Reduce image complexity (fewer bands), increase scale, or use sampling
*
* Problem: "Export completed but file is empty/corrupt"
* - Solution: Check if all pixels masked (flood mask empty), verify band names
*
* ALTERNATIVE EXPORT METHODS (not used in this script):
* - Export.image.toAsset(): Save to GEE Assets (for reuse within GEE)
* - Export.image.toCloudStorage(): Export to Google Cloud Storage (for large files >50 MB)
*
* @param {ee.Image} img - Image to export (single or multiband)
* @param {ee.Geometry} aoi - Export extent (clipping boundary)
* @param {String} name - Task/file name (without .tif extension)
*/
function exportToDrive(img, aoi, name) {
// Early return if export disabled in CONFIG
// Prevents unnecessary task queuing when user just wants Map visualization
if (!CONFIG.EXPORT.enabled) return;
// Queue export task (DOES NOT RUN automatically)
// User must click "RUN" in Tasks tab to execute
Export.image.toDrive({
image: img, // Image to export
description: name, // Task name + filename (without extension)
folder: CONFIG.EXPORT.folder, // Google Drive folder (created if missing)
region: aoi, // Export boundary (must be ee.Geometry)
scale: CONFIG.EXPORT.scale, // Pixel size (meters)
maxPixels: CONFIG.EXPORT.maxPixels, // Safety limit (pixels)
crs: 'EPSG:4326', // Output projection (WGS84 lat/lon)
fileFormat: 'GeoTIFF', // Format (implicit, GEE default)
formatOptions: {
cloudOptimized: true // Export a Cloud Optimized GeoTIFF (COG)
}
});
// Note: No return value; task is queued server-side
// Check Tasks tab (orange icon) to see queued exports
}
/** #############################################
* 6) MAIN WORKFLOW
* #############################################
*
* OVERVIEW:
* This is the main execution script that orchestrates the entire flood mapping workflow.
* It calls all helper functions in sequence to produce final flood maps and diagnostics.
*
* EXECUTION FLOW:
* 1) Setup: Define AOI, histogram mask, date windows
* 2) GRD Processing: Build pre/post composites, compute flood probability per orbit
* 3) Ensemble: Combine ASCENDING + DESCENDING via pixelwise MAX
* 4) Coherence (optional): Load and fuse SLC coherence evidence
* 5) Masking: Apply physics-based plausibility constraints
* 6) Post-processing: Threshold, clean, smooth final flood mask
* 7) Statistics: Compute flooded area in hectares
* 8) Meteorology: Cross-validate with precipitation data
* 9) Visualization: Add all layers to Map for inspection
* 10) Export: Queue GeoTIFF export tasks (if enabled)
*
* EXPECTED RUNTIME:
* - Small AOI (<500 km²): 10-30 seconds
* - Medium AOI (500-5000 km²): 30-120 seconds
* - Large AOI (>5000 km²): 2-5 minutes
* - Note: First run slower (caching), subsequent runs faster
*
* VISUALIZATION STRATEGY:
* - Critical layers visible by default (floodProb, flood extent)
* - Diagnostic layers hidden (click checkbox to show)
* - Per-orbit layers hidden (for advanced debugging)
* - Meteorology layers hidden (toggle on for cross-validation)
*
* DEBUGGING WORKFLOW:
* 1) Check Console prints: image counts, thresholds, area
* 2) Inspect per-orbit layers: verify data availability
* 3) Check deltaVV range: should show darkening in flood areas
* 4) Inspect plausibility masks: understand what's being removed
* 5) Toggle meteorology layers: verify precipitation correlation
* 6) Use Pixel Inspector: click flooded areas to see all band values
*
* CUSTOMIZATION:
* - All tuning parameters in CONFIG (Section 0)
* - Map layer visibility: change 'false' to 'true' in Map.addLayer()
* - Visualization palettes: modify {palette: [...]} arrays
* - Chart options: modify .setOptions({...}) blocks
*/
// ------------------------------------------------------------
// AOI Setup and Map Initialization
// ------------------------------------------------------------
// Resolve AOI based on CONFIG.AOI.mode (GAUL / ASSET / DRAWN / PASTE / AUTO)
// getAOI() handles all mode logic and fallbacks (see Section 1b)
var aoi = getAOI();
// Center map on AOI with automatic zoom calculation
// GEE automatically determines appropriate zoom based on AOI bounds
// Small AOI (city district) → zooms in more (level 12-14)
// Large AOI (province/region) → zooms out more (level 8-10)
Map.centerObject(aoi); // No zoom parameter → automatic
// Set base layer to satellite imagery (better context than terrain/roadmap)
Map.setOptions('SATELLITE');
// Display AOI boundary in yellow (highly visible against satellite imagery)
Map.addLayer(aoi, {color: 'yellow'}, 'AOI');
// ------------------------------------------------------------
// Histogram Mask (Optional Otsu Stabilization)
// ------------------------------------------------------------
// PURPOSE:
// - Ensures consistent Otsu thresholds across AOI shapes (GAUL polygon vs DRAWN bbox)
// - Problem: Ocean in bbox → shifts histogram → different thresholds
// - Solution: Sample histogram only from land-like + not-permanent-water areas
//
// HOW IT WORKS:
// - buildHistogramMask() creates mask from DEM footprint + GSW exclusion
// - This mask is passed to floodProbability() for Otsu threshold estimation ONLY
// - Final probability is computed on full unmasked data (no data loss)
//
// WHEN TO USE:
// - ALWAYS recommended for coastal AOIs (DRAWN bounding boxes near ocean)
// - Less critical for inland AOIs (but doesn't hurt)
// - See CONFIG.HIST for detailed tuning (sampling, memory limits)
var histMask = null;
if (CONFIG.HIST && CONFIG.HIST.enabled) {
histMask = buildHistogramMask(aoi);
// Optional debug layer: visualize what areas are being sampled for Otsu
// Useful for verifying that ocean/lakes are properly excluded
if (CONFIG.HIST.debugLayers) {
// Display as 0/1 so Pixel Inspector shows clear values
Map.addLayer(histMask.unmask(0), // unmask(0): show masked areas as 0 (black) for clarity
{min: 0, max: 1},
'Histogram mask (Otsu sampling)',
false // Hidden by default (toggle on for debugging)
);
}
}
// ------------------------------------------------------------
// Date Window Setup
// ------------------------------------------------------------
// Define temporal windows for compositing:
// - Event window: suspected flood period (when water was present)
// - Baseline window: pre-flood reference (normal conditions for change detection)
//
// IMPORTANT:
// - filterDate() uses half-open intervals: [start, end)
// - We add +1 day to eventEnd to make it inclusive in user terms
// - Example: eventEnd = '2025-11-28' → filterDate uses '2025-11-29' → includes Nov 28
var eventStart = ee.Date(CONFIG.DATES.eventStart);
var eventEnd = ee.Date(CONFIG.DATES.eventEnd).advance(1, 'day'); // end-exclusive for filterDate
var baseStart = eventStart.advance(-CONFIG.DATES.baselineDays, 'day');
var baseEnd = eventStart;
// Print to Console for verification (helps catch date entry errors)
print('Event window:', eventStart, '→', eventEnd);
print('Baseline window:', baseStart, '→', baseEnd);
// ------------------------------------------------------------
// Multi-Orbit GRD Flood Probability Ensemble
// ------------------------------------------------------------
// WHY MULTI-ORBIT ENSEMBLE:
// - Sentinel-1 has two orbit directions: ASCENDING (southward) and DESCENDING (northward)
// - Each orbit "sees" terrain from different angle → different shadow/layover patterns
// - Geometry artifacts can make flood invisible in one orbit but visible in other
// - Example: East-facing slope
// * ASCENDING: sensor looks east → slope in shadow → dark (false flood)
// * DESCENDING: sensor looks west → slope illuminated → bright (no flood)
// * MAX: Takes DESCENDING value → avoids false positive
// - Example: Real flood in valley
// * ASCENDING: visible (dark water)
// * DESCENDING: in shadow (missed)
// * MAX: Takes ASCENDING value → keeps detection
//
// IMPLEMENTATION:
// - Process each orbit separately (separate pre/post composites)
// - Compute flood probability for each orbit independently
// - Combine via pixelwise MAX (keeps highest probability from any orbit)
// - Result: Reduced geometry-driven false negatives AND false positives
//
// IMPORTANT BAND NAMING:
// - Each orbit's floodProbability() returns multiband image
// - We extract ONLY 'floodProb' band for ensemble (common name across orbits)
// - This ensures .max() works correctly (same band name in all images)
//
var probPerOrbit = CONFIG.S1.ORBITS.map(function(orbit) {
// Build temporal composites for this orbit
// Pre: median over baseline (stable reference)
// Post: min over event (captures peak flood / darkest backscatter)
var pre = buildS1Composite(aoi, orbit, baseStart, baseEnd, CONFIG.S1.temporalReducerPre);
var post = buildS1Composite(aoi, orbit, eventStart, eventEnd, CONFIG.S1.temporalReducerPost);
// DEBUG LAYERS: Per-orbit VV backscatter
// Useful for diagnosing:
// - Data availability (are there S1 images for this orbit?)
// - Spatial patterns (where is VV dark? where is it bright?)
// - Temporal change (is post darker than pre in flood areas?)
// Hidden by default (toggle on for advanced debugging)
Map.addLayer(
pre.select('VV'),
{min:-25, max:0}, // VV range: -25 dB (very dark water) to 0 dB (bright urban/bare soil)
'Pre VV (' + orbit + ')',
false // Hidden (turn on to inspect baseline backscatter)
);
Map.addLayer(
post.select('VV'),
{min:-25, max:0},
'Post VV (' + orbit + ')',
false // Hidden (turn on to inspect flood-time backscatter)
);
// Compute flood probability for this orbit
// Combines three evidence types: change (ΔVV), absolute (VV_post), ratio (VH/VV)
// histMask: optional mask for stable Otsu threshold estimation
var probImg = floodProbability(pre, post, aoi, histMask);
// DEBUG LAYERS: Per-orbit flood probability and change
// Useful for:
// - Comparing orbit performance (which orbit detects flood better?)
// - Understanding change patterns (where did VV decrease most?)
// - Validating Otsu thresholds (check Console prints)
Map.addLayer(
probImg.select('floodProb'),
{min:0, max:1}, // Probability: 0 (unlikely) to 1 (certain)
'FloodProb (' + orbit + ')',
false // Hidden (turn on to compare per-orbit probabilities)
);
Map.addLayer(
probImg.select('deltaVV'),
{min:-10, max:5}, // ΔVV: -10 dB (strong darkening) to +5 dB (brightening)
'ΔVV dB (' + orbit + ')',
false // Hidden (turn on to see change magnitude)
);
// Return ONLY the flood probability band with standardized name
// This ensures all orbits have same band name for .max() ensemble
return probImg.select('floodProb').rename('floodProb');
});
// ENSEMBLE: Pixelwise MAX across all orbits
// For each pixel: take highest flood probability from any orbit
// Result: Robust detection that uses best evidence from each orbit
var grdProbEns = ee.ImageCollection.fromImages(probPerOrbit).max().rename('floodProb');
// MAIN LAYER: GRD Ensemble Flood Probability
// This is the primary output before coherence fusion
// Visible by default (core result layer)
Map.addLayer(
grdProbEns,
{min:0, max:1}, // 0 = no flood evidence, 1 = strong flood evidence
'Flood Probability (GRD Ensemble MAX)',
true // VISIBLE by default
);
// ------------------------------------------------------------
// Optional Coherence Evidence (SLC-derived)
// ------------------------------------------------------------
// WHY COHERENCE:
// - Coherence measures phase similarity between two SAR acquisitions
// - Low coherence indicates surface change (flood, landslide, vegetation growth)
// - Complements GRD: GRD detects "dark surfaces", coherence detects "surface change"
// - Reduces false positives: smooth surfaces (parking lots) are dark in GRD but stay coherent
//
// COHERENCE DROP:
// - Compare pre-event coherence to event coherence
// - Large drop (e.g., 0.7 → 0.2) indicates NEW surface change (flood)
// - Helps distinguish flood from persistent low-coherence areas (vegetation, water bodies)
//
// LIMITATION:
// - Requires external SLC processing outside GEE (SNAP, ASF HyP3, ISCE2)
// - Must upload processed coherence GeoTIFF to GEE Assets
// - Not available by default (most users skip this step)
//
// USAGE:
// - Set CONFIG.COH.enabled = true
// - Provide CONFIG.COH.eventCohAssetId (REQUIRED)
// - Optionally provide CONFIG.COH.preCohAssetId (enables coherence drop)
var cohEv = coherenceEvidence(aoi);
// Display coherence layers only if enabled
// Hidden by default even if enabled (advanced diagnostic layers)
if (CONFIG.COH.enabled) {
Map.addLayer(
cohEv.select('coh_event'),
{min:0, max:1}, // Coherence: 0 (decorrelated) to 1 (coherent)
'Coherence (event)',
false // Hidden (turn on to inspect event coherence)
);
Map.addLayer(
cohEv.select('cohProb'),
{min:0, max:1}, // Coherence-based flood probability
'Coherence Probability',
false // Hidden (turn on to see coherence contribution)
);
Map.addLayer(
cohEv.select('dCoh'),
{min:-0.2, max:0.6}, // Coherence drop: negative = increased coherence (rare), positive = decreased
'ΔCoherence (pre - event)',
false // Hidden (turn on to see coherence change)
);
}
// ------------------------------------------------------------
// Fusion (GRD + Coherence)
// ------------------------------------------------------------
// Weighted linear blend of GRD and coherence probabilities
// Default weights: GRD = 0.75 (dominant), Coherence = 0.25 (supporting)
//
// WHY WEIGHTED (not equal):
// - GRD is more reliable for most flood types (open water, riverine)
// - Coherence is most useful for urban flooding and false positive reduction
// - Coherence can be noisy over vegetation (temporal decorrelation not just flood)
//
// BEHAVIOR:
// - If CONFIG.COH.enabled = false: returns GRD probability unchanged (no fusion)
// - If enabled: fused = 0.75 * P_grd + 0.25 * P_coh
// - Result: Coherence slightly boosts/suppresses GRD probability
var finalProb = fuseFloodProbability(grdProbEns, cohEv.select('cohProb'));
// MAIN LAYER: Fused Flood Probability (GRD + optional coherence)
// This is the final probability before masking
// Visible by default (final result layer)
Map.addLayer(
finalProb,
{min:0, max:1},
'Flood Probability (Fused)',
true // VISIBLE by default
);
// ------------------------------------------------------------
// Plausibility Masking + Post-Processing
// ------------------------------------------------------------
// Apply physics-based constraints to remove implausible flood detections:
// 1) Slope: Remove steep terrain (>5° default)
// - Why: Radar shadow/layover artifacts on mountains
// - Trade-off: May remove real flood on riverbanks in hilly terrain
// 2) HAND: Keep only low terrain near rivers (<=40m default)
// - Why: Floodwater flows to low areas near drainage network
// - Trade-off: May miss coastal/pluvial flooding away from rivers
// 3) Permanent water: Exclude lakes/reservoirs (GSW occurrence >= 85%)
// - Why: Not "flood" but pre-existing water
// - Trade-off: May remove flood in seasonal wetlands if threshold too low
//
// Post-processing:
// - Threshold continuous probability → binary mask (>= 0.35 default)
// - Remove small connected components (< 5 pixels default = speckle noise)
// - Optional morphological smoothing (dilation → erosion, if enabled)
var masked = applyMasksAndPost(finalProb, aoi);
var floodMask = masked.select('flood');
// LAYER: Masked flood probability (continuous 0-1, after plausibility masks)
// Useful for understanding what was removed by masking
Map.addLayer(
masked.select('floodProbMasked'),
{min:0, max:1},
'Flood Prob (masked)',
false // Hidden (turn on to see masked probability)
);
// MAIN LAYER: Binary flood extent (final result)
// Cyan color highly visible against satellite imagery
// Visible by default (PRIMARY OUTPUT)
Map.addLayer(
floodMask,
{palette:['00FFFF']}, // Cyan (RGB: 0,255,255)
'Flood Extent (binary)',
true // VISIBLE by default
);
// ------------------------------------------------------------
// Diagnostic Layers (Plausibility Masks)
// ------------------------------------------------------------
// Visualize what was masked out and why
// Hidden by default (toggle on to understand mask behavior)
// Permanent water mask (JRC Global Surface Water)
// Black = not permanent, Blue = permanent water (masked out)
Map.addLayer(
masked.select('permWater'),
{min:0, max:1, palette:['000000','0000FF']},
'Permanent water (GSW)', // Black to Blue
false // Hidden (turn on to see what was masked as permanent water)
);
// HAND: Height Above Nearest Drainage (meters)
// Blue (low, near rivers) → Yellow → Red (high, far from rivers)
// Shows which areas passed HAND test (<= maxHANDm)
Map.addLayer(
masked.select('hand'),
{
min: 0,
max: 50, // 0-50m range (most flood-relevant HAND values)
palette: [
'0b1d51', // Dark blue (0m, river level)
'1e6db2', // Blue (10m)
'2fb47c', // Cyan (20m)
'a6d96a', // Green (30m)
'fdae61', // Yellow (40m)
'd7191c' // Red (50m, high/far from rivers)
],
opacity: 0.85 // Slightly transparent to see satellite underneath
},
'HAND (m)',
false // Hidden (turn on to see terrain proximity to rivers)
);
// Slope: Terrain steepness (degrees)
// Dark blue (flat) → Yellow → Red (steep)
// Shows which areas passed slope test (<= maxSlopeDeg)
Map.addLayer(
masked.select('slope'),
{
min: 0,
max: 15, // 0-15° range (most flood-relevant slope values)
palette: [
'001219', // Dark blue (0°, flat)
'005f73', // Blue (3°)
'0a9396', // Cyan (5°)
'94d2bd', // Light cyan (7°)
'e9d8a6', // Beige (9°)
'ee9b00', // Orange (11°)
'ca6702', // Dark orange (13°)
'bb3e03', // Red-orange (14°)
'ae2012' // Red (15°, steep)
],
opacity: 0.85
},
'Slope (deg)',
false // Hidden (turn on to see terrain steepness)
);
// ------------------------------------------------------------
// Flood Area Statistics
// ------------------------------------------------------------
// Compute and print total flooded area to Console
// Uses pixel-accurate area calculation (accounts for projection distortion)
// Output: "Flooded area (ha): XXXX.X"
printFloodAreaHa(floodMask, aoi);
// ------------------------------------------------------------
// Meteorology Cross-Check Layers
// ------------------------------------------------------------
// PURPOSE:
// - Verify flood plausibility with independent precipitation data
// - Cross-validate: Did it actually rain enough to cause flooding?
// - Identify meteorological drivers: Tropical cyclone? Frontal system?
//
// DATASETS:
// - IMERG (NASA GPM): Satellite precipitation estimates (30-min, ~11 km)
// - ECMWF IFS: Numerical weather model (hourly, ~9 km)
//
// INTERPRETATION:
// - SAR flood + 100-300mm IMERG → HIGH confidence (pluvial/riverine flood)
// - SAR flood + <20mm IMERG → INVESTIGATE (dam release? tide? false positive?)
// - Strong vorticity (850 hPa) → tropical cyclone or monsoon depression
// IMERG: Total precipitation accumulation over event window
var imerg = imergAccumMm(aoi, eventStart, eventEnd);
Map.addLayer(
imerg,
{min:0, max:300}, // 0-300mm range (typical flood-producing rainfall)
'IMERG precip accumulation (mm)',
false // Hidden (turn on to see rainfall total)
);
// ECMWF IFS: Mean atmospheric diagnostics over event window
var ifs = ifsDiagnostics(aoi, eventStart, eventEnd);
// IFS precipitation rate (mm/hr, mean over window)
Map.addLayer(
ifs.select('ifs_prate_mmhr'),
{min:0, max:10}, // 0-10 mm/hr (0=dry, 5-10=heavy continuous rain)
'ECMWF IFS precip rate (mm/hr)',
false // Hidden
);
// IFS vorticity at 850 hPa (s^-1, cyclonic rotation indicator)
// Positive (NH) = counterclockwise = low pressure system
Map.addLayer(
ifs.select('ifs_vort850'),
{min:-0.0005, max:0.0005}, // Negative = anticyclonic, Positive = cyclonic
'ECMWF IFS vorticity 850hPa',
false // Hidden
);
// IFS 10m wind speed (m/s, storm intensity indicator)
Map.addLayer(
ifs.select('ifs_wspd10'),
{min:0, max:15}, // 0-15 m/s (0=calm, >10=strong winds, >20=hurricane force)
'ECMWF IFS 10m wind speed',
false // Hidden
);
// ------------------------------------------------------------
// Precipitation Time Series (with Cumulative)
// ------------------------------------------------------------
// Chart shows both:
// 1) Instantaneous rate (mm/hr, left Y-axis) → when did heavy rain occur?
// 2) Running accumulation (mm, right Y-axis) → how much rain total?
//
// USAGE:
// - Correlate precipitation peaks with SAR acquisition times
// - Verify flood timing: Did peak rain occur before/during SAR acquisition?
// - Understand storm evolution: Single burst? Prolonged event?
var imergTS = imergSeriesWithCumulative(aoi, eventStart, eventEnd);
// IMERG time series chart (dual Y-axis)
// NOTE: GEE Charts quirk - vAxes[0]=RIGHT, vAxes[1]=LEFT (counterintuitive!)
print(ui.Chart.image.series({
imageCollection: imergTS,
region: aoi,
reducer: ee.Reducer.mean(),
scale: 10000
}).setOptions({
title: 'IMERG precipitation (AOI mean) — rate (mm/hr) + cumulative (mm)',
lineWidth: 2,
pointSize: 0,
// Data routing (indices REVERSED from visual position)
series: {
0: {targetAxisIndex: 0}, // rate_mmhr → axis 0 → RIGHT axis (visual)
1: {targetAxisIndex: 1} // accum_mm → axis 1 → LEFT axis (visual)
},
// Axis labels (indices MATCH visual position)
vAxes: {
0: {title: 'Cumulative (mm)', minValue: 0}, // vAxes[0] → LEFT axis (where accum displays)
1: {title: 'Rate (mm/hr)', minValue: 0} // vAxes[1] → RIGHT axis (where rate displays)
},
hAxis: {title: 'Date'},
interpolateNulls: false
}));
// ECMWF IFS precipitation time series (single axis)
// Shows model-based precipitation rate over time
var ifsIC = ee.ImageCollection(CONFIG.MET.ifsCollection)
.filterBounds(aoi)
.filterDate(eventStart, eventEnd)
.select('total_precipitation_rate_sfc');
print(ui.Chart.image.series({
imageCollection: ifsIC.map(function(img){
// Convert kg m^-2 s^-1 to mm/hr (×3600)
return img.multiply(3600).rename('mmhr').copyProperties(img, ['system:time_start']);
}),
region: aoi,
reducer: ee.Reducer.mean(),
scale: 28000 // ~30 km resolution (IFS effective resolution for precip)
}).setOptions({
title: 'ECMWF IFS precip rate (AOI mean, mm/hr) — event window',
lineWidth: 2,
pointSize: 1, // Small points (hourly data, fewer timesteps than IMERG)
hAxis: {title: 'Date'},
vAxis: {title: 'Precipitation rate (mm/hr)'}
}));
// ------------------------------------------------------------
// Optional Export to Google Drive
// ------------------------------------------------------------
// Export final outputs as GeoTIFF rasters for offline analysis
//
// CRITICAL: Tasks don't auto-run!
// 1) This code QUEUES export tasks (adds them to Tasks tab)
// 2) Tasks tab shows orange icon (top-right) when tasks are queued
// 3) You MUST click "RUN" on each task to execute export
// 4) Tasks turn blue (running) → green (complete) when done
// 5) Find outputs in Google Drive → GEE_FLOOD folder
//
// OUTPUTS (if CONFIG.EXPORT.enabled = true):
// 1) FloodProb_Fused_YYYY-MM-DD.tif: Continuous probability (Float32, 0-1)
// 2) FloodMask_YYYY-MM-DD.tif: Binary extent (Byte, 0 or 1)
// 3) IMERGaccum_YYYY-MM-DD.tif: Total precipitation (Float32, mm)
// 4) IFSdiagnostics_YYYY-MM-DD.tif: Multiband atmospheric data (Float32)
//
// TUNING:
// - Scale (CONFIG.EXPORT.scale): 10m=best quality, 30m=faster
// - Large AOIs: May need to increase scale or split into tiles
// - Check Tasks tab for export progress and error messages
//
exportToDrive(finalProb, aoi, 'FloodProb_Fused_' + CONFIG.DATES.eventStart);
exportToDrive(floodMask, aoi, 'FloodMask_' + CONFIG.DATES.eventStart);
exportToDrive(imerg, aoi, 'IMERGaccum_' + CONFIG.DATES.eventStart);
exportToDrive(ifs, aoi, 'IFSdiagnostics_' + CONFIG.DATES.eventStart);
/** #############################################
* END OF SCRIPT
* #############################################
*
* NEXT STEPS:
* 1) Inspect Map layers (toggle visibility to explore results)
* 2) Check Console prints (image counts, thresholds, flooded area)
* 3) Use Pixel Inspector (click flooded areas to see all band values)
* 4) Review time series charts (precipitation timing and accumulation)
* 5) Adjust CONFIG parameters if needed (thresholds, weights, masks)
* 6) Re-run script to see effects of parameter changes
* 7) Enable exports (CONFIG.EXPORT.enabled=true) when satisfied
* 8) Click "RUN" in Tasks tab to download results
*
* TROUBLESHOOTING:
* - "No flood detected": Check precipitation (was there rain?), lower threshold
* - "Too much flood": Check permanent water mask, increase threshold
* - "Computation timeout": Reduce AOI size, increase scale, or simplify masks
* - "Memory exceeded": Enable histogram sampling (CONFIG.HIST.useSampling=true)
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment