---
name: "landsat-water-detection"
version: 2.0.0
description: "Generates Google Earth Engine JavaScript script for Landsat 8/9 water body detection using MNDWI from AOI and event date. Includes fix for black export GeoTIFFs, fallback TOA, and QGIS stretch instructions."
model_tier: 2
metadata:
  hermes:
    tags: [landsat, water, mndwi, gee, flood, export-nero]
    related_skills: [sentinel-2-water, flood-riverine, sentinel-1-flood-v3]
---

# 🛰️ Landsat 8/9 Water Detection — MNDWI (GEE)

## Scopo

Genera uno script JavaScript pronto all'uso per **Google Earth Engine (GEE)** che esegue la mappatura delle acque superficiali usando l'indice **MNDWI** da immagini **Landsat 8 (LC08) e Landsat 9 (LC09)**, Collection 2 Level 2 (SR) con fallback a TOA.

Include la **soluzione definitiva** per il problema dei file GeoTIFF neri in QGIS.

## Input Richiesti

- **AOI** — `ee.Geometry.MultiPolygon` coordinate EPSG:4326 (se grande, MultiPolygon, non Polygon singolo)
- **Event Date** — formato `YYYY-MM-DD`
- **Window** — ±30gg default, ±60gg fallback
- **Cloud** — 50% default per zone tropicali

## Output

- Script GEE JavaScript salvato in `~/.hermes/generated/landsat_water_v2.js`
- **3 export GeoTIFF**: True Color, MNDWI, Water Mask — **NON più neri!**
- Consegna via HTTP (`http://localhost:8080/generated/landsat_water_v2.js`)

## ⚠️ La Cosa Più Importante: Perché I File Erano Neri

### Causa
Landsat C2 L2 ha valori reali **0-0.3** (float a 32 bit). QGIS apre con stretch automatico 0-255 → tutto nero.

**❌ NON fare:**
```javascript
image.select(['B4','B3','B2']).float()  // valori 0-0.3 → nero in QGIS
```

**✅ Fare:**
```javascript
image.select(['B4','B3','B2']).multiply(10000).toUint16()
```

### Come aprire in QGIS
- **TrueColor**: Singleband Gray → Min: 0, Max: **3000** (o "Stretch to Min Max")
- **MNDWI**: Singleband Gray → Min: 0, Max: **20000** (valori < 10000 = non acqua, > 10000 = acqua)
- **WaterMask**: Paletted → 0=nero, 65535=blu

## Script Template v2 Fixed

```javascript
// 🌍 LANDSAT 8/9 WATER DETECTION v2 FIXED
// AOI: <NOME> | Data: <DATA> | Cloud: <50>%

var aoi = ee.Geometry.MultiPolygon([
  // ... tile
]);
Map.centerObject(aoi, 10);
Map.addLayer(aoi, {color: 'red'}, 'AOI');

var l8 = ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')
  .filterBounds(aoi).filterDate('<DATE-30d>', '<DATE+30d>')
  .filter(ee.Filter.lt('CLOUD_COVER', 50));
var l9 = ee.ImageCollection('LANDSAT/LC09/C02/T1_L2')
  .filterBounds(aoi).filterDate('<DATE-30d>', '<DATE+30d>')
  .filter(ee.Filter.lt('CLOUD_COVER', 50));

var nL8 = l8.size(), nL9 = l9.size();
print('L8:', nL8, 'L9:', nL9);

// Fallback TOA se L2 = 0
var merged;
if (nL8.add(nL9).gt(0)) {
  merged = l8.merge(l9).sort('CLOUD_COVER');
} else {
  print('⚠ Fallback TOA');
  var l8t = ee.ImageCollection('LANDSAT/LC08/C02/T1_TOA')
    .filterBounds(aoi).filterDate('<DATE-30d>', '<DATE+30d>')
    .filter(ee.Filter.lt('CLOUD_COVER', 50));
  var l9t = ee.ImageCollection('LANDSAT/LC09/C02/T1_TOA')
    .filterBounds(aoi).filterDate('<DATE-30d>', '<DATE+30d>')
    .filter(ee.Filter.lt('CLOUD_COVER', 50));
  merged = l8t.merge(l9t).sort('CLOUD_COVER');
}

var raw = merged.first();
print('Sat:', raw.get('SPACECRAFT_ID'), 'Date:', raw.date());

// Scaling
var useL2 = nL8.add(nL9).gt(0);
var image = ee.Image(ee.Algorithms.If(
  useL2,
  raw.multiply(0.0000275).subtract(0.2),
  raw.multiply(0.0000275)
)).clip(aoi);

// MNDWI: (B3 - B6) / (B3 + B6)
var mndwi = image.normalizedDifference(['B3','B6']).rename('MNDWI');
var water = mndwi.gt(0).rename('water');

// ─── 4 Layer ───
Map.addLayer(image, {bands:['B4','B3','B2'], min:0, max:0.3, gamma:1.4}, '1. True Color');
Map.addLayer(image, {bands:['B7','B5','B4'], min:0, max:0.4, gamma:1.2}, '2. False Color');
Map.addLayer(mndwi, {min:-0.5, max:0.5, palette:['brown','white','blue']}, '3. MNDWI');
Map.addLayer(water.updateMask(water), {palette:'0000ff'}, '4. Water Mask');

// Stats
var wp = water.reduceRegion({reducer:ee.Reducer.sum(), geometry:aoi, scale:30, bestEffort:true, maxPixels:1e9});
var waterPx = ee.Number(wp.get('water'));
print('Pixel acqua:', waterPx);
print('Area km²:', waterPx.multiply(900).divide(1e6));

// ═══════════════════════════════════════════
// ✅ EXPORT ×10000 → uint16 (NON nero!)
// ═══════════════════════════════════════════

// M1: True Color ×10000
Export.image.toDrive({
  image: image.select(['B4','B3','B2']).multiply(10000).toUint16(),
  description: 'Landsat_TrueColor_*',
  folder: 'GEE_exports', region: aoi, scale: 30,
  crs: 'EPSG:4326', maxPixels: 1e9, fileFormat: 'GeoTIFF'
});

// M2: MNDWI offset (mndwi+1)*10000 → 0-20000
Export.image.toDrive({
  image: mndwi.add(1).multiply(10000).toUint16(),
  description: 'Landsat_MNDWI_*',
  folder: 'GEE_exports', region: aoi, scale: 30,
  crs: 'EPSG:4326', maxPixels: 1e9, fileFormat: 'GeoTIFF'
});

// M3: Water Mask 0/65535
Export.image.toDrive({
  image: water.where(water.eq(1), 65535).toUint16(),
  description: 'Landsat_WaterMask_*',
  folder: 'GEE_exports', region: aoi, scale: 30,
  crs: 'EPSG:4326', maxPixels: 1e9, fileFormat: 'GeoTIFF'
});
```

## Differenze con Sentinel-2

| Aspetto | Sentinel-2 | Landsat 8/9 |
|---|---|---|
| Rivisita | 5gg | 16gg |
| Risoluzione | 10m | 30m |
| Bande MNDWI | B3 (Green), B11 (SWIR) | B3 (Green), B6 (SWIR1) |
| Scaling SR | /10000 (già intero) | *0.0000275 - 0.2 (float) |
| Export nero | Raro | **SEMPRE** (valori 0-0.3) |
| Cloud filtro | 20-50% | 50% (poche immagini) |
| Collezione L2 | `COPERNICUS/S2_SR_HARMONIZED` | `LANDSAT/LC08/C02/T1_L2` |
| Fallback | L1C | TOA |
| Nome bande L2 | B3, B11 | SR_B3, SR_B6 |
| Nome bande TOA | B3, B11 | B3, B6 |

## Bande Landsat 8/9 Collection 2

| Banda | Nome | Uso |
|---|---|---|
| B1 | Coastal aerosol | — |
| B2 | Blue | True Color R |
| B3 | Green | True Color G, **MNDWI** |
| B4 | Red | True Color B |
| B5 | NIR | NDWI |
| B6 | SWIR1 | **MNDWI** |
| B7 | SWIR2 | False Color |

- **L2**: prefisso `SR_` (es. `SR_B3`)
- **TOA**: senza prefisso (es. `B3`)

## Fallback Strategy

1. PROVA: L8+L9 Collection 2 L2, cloud < 50%
2. SE 0: TOA (L1), cloud < 50%
3. SE 0: raddoppia finestra (±60gg)
4. SE 0: cloud < 80%

## Consegna Script

### Server HTTP — accessibile da remoto
Il server **deve** ascoltare su `0.0.0.0`, non solo localhost, altrimenti l'utente su altro PC/telefono non può scaricare lo script.

```bash
# ❌ SOLO locale (non funziona da remoto)
python3 -m http.server 8088 --directory ~/.hermes/generated

# ✅ Accessibile da qualsiasi IP
python3 -m http.server 8088 --directory ~/.hermes/generated --bind 0.0.0.0
```

### URL da consegnare all'utente
```bash
# Tramite Tailscale (VPN)
http://100.86.154.25:8088/landsat_water_v2.js

# Tramite LAN
http://192.168.1.100:8088/landsat_water_v2.js

# Tramite porta 8080 (serve da ~/.hermes/)
http://<ip>:8080/generated/landsat_water_v2.js
```

### Verifica
```bash
curl -s -o /dev/null -w "%{http_code}" http://<ip>:8088/landsat_water_v2.js
# Deve stampare 200
```

## Pitfalls

1. **File neri in QGIS** — Causa #1. Landsat = float 0-0.3. **Sempre ×10000 → uint16.**
2. **Poche immagini** — 16gg rivisita + nuvole. L8+L9 insieme, cloud 50%, fallback TOA.
3. **Bande diverse tra L2 e TOA** — L2: `SR_B3`, TOA: `B3`. Usare entrambe.
4. **URL errato** — Server su 8080 serve da `~/.hermes/`, file in `generated/`. Usare `/generated/`.
5. **Server su localhost** — Se l'utente è su un altro PC, `localhost:8088` non funziona. **Sempre `--bind 0.0.0.0`** e consegnare IP Tailscale o LAN.
6. **AOI singolo** — MultiPolygon per AOI grandi (tile Landsat ~170×183 km? No, ma watershed può essere grande).
7. **Soglia MNDWI** — `gt(0)` default. `gt(-0.1)` per fiumi, `gt(0.1)` per laghi.
8. **Export multipli** — GEE fa UN export per Run. Tutti e 3 vanno runnati separati.

## Verification Checklist

- [ ] Script in `~/.hermes/generated/landsat_water_v2.js`
- [ ] Export: `.multiply(10000).toUint16()` (non `.float()`)
- [ ] True Color: bande B4,B3,B2
- [ ] MNDWI: bande B3,B6
- [ ] Fallback TOA se L2 = 0
- [ ] Water Mask: 0 o 65535
- [ ] URL accessibile via HTTP
- [ ] QGIS istruzioni per stretch min/max
