diff --git a/README.md b/README.md index 2ce1af7..ceafc27 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,30 @@ # Satellite Data Fusion Pipeline -A Python pipeline for downloading, processing, and fusing Sentinel-2 and Sentinel-3 satellite imagery to generate high-resolution NDVI time series. +Python pipeline for downloading Sentinel-2 and Sentinel-3 imagery and PhenoCam ground truth, applying NDVI-based cloud pre-selection, fusing sensors with the [EFAST](https://github.com/DHI-GRAS/efast) algorithm, and evaluating fused **Green Chromatic Coordinate (GCC)** time series against PhenoCam `gcc_90`. ## Features -- **Data Download**: Downloads Sentinel-2 L2A (via AWS Earth Search) and Sentinel-3 OLCI (via OpenEO/Copernicus) -- **Cloud Detection**: Identifies cloud-covered images using NDVI analysis -- **EFAST Fusion**: Combines S2 and S3 data using the EFAST algorithm for enhanced temporal resolution -- **NDVI Calculation**: Generates Normalized Difference Vegetation Index from raw and fused data -- **Web Visualization**: Interactive web viewer for exploring NDVI time series and imagery +- **Acquisition** — S2 L2A (AWS Element84 STAC), S3 OLCI L1B (Copernicus OpenEO), PhenoCam midday images and GCC CSV +- **Pre-selection** — Aggressive and non-aggressive NDVI-based cloud screening (plus dark-scene rejection) +- **Preparation** — Harmonised reflectance/GCC rasters, distance-to-cloud weights, S3 compositing and optional temporal smoothing +- **Fusion** — EFAST under eight scenarios per site (BtI and ItB × two strategies × σ ∈ {20, 30} days) +- **Post-processing** — Crop to valid-data window; NDVI and GCC timeseries at the site +- **Metrics** — Temporal comparison vs PhenoCam (`metrics.json`); optional Tier-2 withheld-S2 gap validation +- **Web viewer** — Static HTML dashboard over pipeline outputs (`webapp/`) ## Installation ```bash pip install -r requirements.txt -pip install git+https://github.com/DHI-GRAS/efast.git +pip install git+https://github.com/DHI-GRAS/efast.git # not on PyPI ``` -## Configuration +Create `.env` with Copernicus Data Space credentials: -Set environment variables for Copernicus Data Space authentication: -- `CDSE_USER`: Copernicus Data Space username -- `CDSE_PASSWORD`: Copernicus Data Space password +- `CDSE_USER` +- `CDSE_PASSWORD` + +Python version is pinned in `.python-version` (use `.venv/` locally). ## Usage @@ -31,54 +34,98 @@ from run import run_pipeline run_pipeline(season=2024, site_position=(47.116171, 11.320308), site_name="innsbruck") ``` -The pipeline processes data in stages: -1. Download S2/S3 imagery -2. Generate NDVI from raw data -3. Detect clouds -4. Prepare data for fusion -5. Run EFAST fusion -6. Generate NDVI from fused outputs +`site_position` is always **`(lat, lon)`**. Study sites are listed at the bottom of `run.py`: `innsbruck`, `forthgr`, `pitsalu`, `vindeln2`, `sunflowerjerez1`, `institutekarnobat`. -## Data Structure +By default, most stages in `run.py` are **commented out** (metrics-only). Uncomment acquisition → pre-selection → preparation → fusion → post-processing for a full run. -``` -data/ - {site_name}/ - {season}/ - raw/ - s2/ # Sentinel-2 GeoTIFFs - s3/ # Sentinel-3 GeoTIFFs - ndvi/ # NDVI from raw data - prepared/ - s2/ # Prepared S2 data - s3/ # Prepared S3 data - fusion/ # EFAST fusion outputs - ndvi/ # NDVI from prepared/fused data - clouds.json # Cloud detection results -``` +### Pipeline stages -### File Formats +1. Download S2, S3, and PhenoCam +2. Pre-selection (per-sensor NDVI screening → `raw/preselection/`) +3. Prepare S2/S3 for each strategy (`prepared_{aggressive|nonaggressive}/` and `_itb/` variants) +4. EFAST fusion (BtI reflectance and ItB GCC products) +5. Post-process crops and timeseries (`processed_*_sigma{20,30}/`) +6. Compute metrics vs PhenoCam → `metrics.json` -**Sentinel-2 (raw/s2/)**: Multi-band GeoTIFF -- Bands: B02 (blue), B03 (green), B04 (red), B8A (nir) -- Metadata: `VIEWING_ZENITH_ANGLE` tag (degrees) -- Filename: `{YYYYMMDD}_{increment}.geotiff` +### Gap validation (optional) -**Sentinel-3 (raw/s3/)**: Multi-band GeoTIFF -- Bands: SDR_Oa04 (blue), SDR_Oa06 (green), SDR_Oa08 (red), SDR_Oa17 (nir) -- Filename: `{YYYYMMDD}_{increment}.geotiff` - -## Web Viewer - -Run a local HTTP server from the **webapp** directory: +With prepared data and EFAST installed: ```bash -cd webapp -python3 -m http.server 8000 +python -m gap_validation.run --site innsbruck --season 2024 --lat 47.116171 --lon 11.320308 ``` -Then open `http://localhost:8000/` in your browser. Data is served via the `webapp/data` symlink. +Writes `data/{site}/{season}/validation/gap_manifest.json`, `gap_validation_summary.json`, and masked fusion under `validation/fusion/`. See `python -m gap_validation.run --help`. + +## Data layout + +``` +data/{site_name}/{season}/ + raw/ + s2/ # {YYYYMMDD}_{n}.geotiff — B02, B03, B04, B8A + s3/ # {YYYYMMDD}_{n}.geotiff — Oa04, Oa06, Oa08, Oa17 + phenocam/ # JPEGs, GCC JSON, phenology sidecar + preselection/ # {s2,s3}_preselection.{json,csv} + prepared_{strategy}/ + s2/ # REFL + DIST_CLOUD GeoTIFFs + s3/ # composite_{YYYYMMDD}.tif + fusion/ # REFL_{YYYYMMDD}.tif (σ≈20) + fusion_sigma30/ # REFL (σ=30) + prepared_{strategy}_itb/ + s2/ s3/ fusion/ # GCC products (Index-then-Blend) + processed_{strategy}_sigma{20,30}/ + s2/ s3/ fusion/ # cropped {YYYYMMDD}_0.geotiff + gcc/ ndvi/ # timeseries.json per source + processed_{strategy}_itb_sigma{20,30}/ + s2/ s3/ fusion/ gcc/ + validation/ # gap experiment (when run) + metrics.json +``` + +Site metadata: `data/sites.geojson` (six thesis sites). `data/coweeta/` is local/legacy and not listed there. + +### File formats + +**Sentinel-2** — Multi-band GeoTIFF; bands `[blue, green, red, nir]`; `VIEWING_ZENITH_ANGLE` metadata; filename `{YYYYMMDD}_{increment}.geotiff`. + +**Sentinel-3** — Multi-band GeoTIFF; same band order; filename `{YYYYMMDD}_{increment}.geotiff`. + +**Prepared S2** — `S2A_MSIL2A_{YYYYMMDD}_REFL.tif` plus `*DIST_CLOUD.tif` (cloud-distance weights for EFAST). + +## Web viewer + +Static HTML/JS in `webapp/` — no build step. Shared GeoTIFF helpers: `webapp/common.js`. CDN: Leaflet, geotiff.js, proj4. Symlink: `webapp/data` → `../data`. + +Serve from the **repository root** (not `webapp/`): + +```bash +python3 -m http.server 8000 +# http://localhost:8000/webapp/index.html +``` + +Or from the workspace root: `make serve`. + +| Page | Purpose | Primary data paths | +|------|---------|-------------------| +| `index.html` | Post-processed maps, NDVI/GCC timeseries, PhenoCam | `processed_{strategy}_sigma{n}/`, `raw/phenocam/` | +| `preselection.html` | Cloud-screening diagnostics | `raw/preselection/{s2,s3}_preselection.json` | +| `prepared.html` | Prepared REFL/GCC before crop | `prepared_{strategy}/`, `prepared_{strategy}_itb/` | +| `fusion.html` | EFAST daily fusion rasters | `prepared_*/fusion/`, `fusion_sigma30/` | +| `postprocessed.html` | Cropped processed stacks | `processed_*_sigma*/` | +| `metrics.html` | Tabular `metrics.json` (thesis export source) | `{site}/{season}/metrics.json` under `webapp/data/` | +| `gap_validation.html` | Withheld-S2 gap experiment | `{site}/{season}/validation/gap_validation_summary.json` | +| `phenology.html` | TIMESAT on PhenoCam GCC | `raw/phenocam/phenocam_phenology.json` | + +Site/season dropdowns use `data/sites.geojson`. Map pages: **BtI | ItB**; scenarios `aggressive` / `nonaggressive`, σ 20 / 30. Keep the shared nav consistent across all eight pages. QA only — thesis tables are exported from the workspace root (`make export` or `../scripts/export_thesis_tables.py`). + +## Development + +```bash +ruff check --fix . && ruff format . +``` + +Pre-commit hooks: `.pre-commit-config.yaml`. ## License -This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the [LICENSE](LICENSE) file for details. +GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE). diff --git a/fusion.py b/fusion.py index 22dda00..fd53b4a 100644 --- a/fusion.py +++ b/fusion.py @@ -24,14 +24,20 @@ def run_efast( cleaning_strategy="aggressive", sigma=None, date_range=None, + *, + s2_output_dir=None, + s3_output_dir=None, + fusion_output_dir=None, ): lat, lon = site_position datetime_range = date_range or f"{season}-01-01/{season}-12-31" efast_base_dir = _get_base_dir(season, site_name, cleaning_strategy) - s2_output_dir = efast_base_dir / "s2" - s3_output_dir = efast_base_dir / "s3" - fusion_output_dir = efast_base_dir / (f"fusion_sigma{sigma}" if sigma else "fusion") + s2_output_dir = s2_output_dir or (efast_base_dir / "s2") + s3_output_dir = s3_output_dir or (efast_base_dir / "s3") + fusion_output_dir = fusion_output_dir or ( + efast_base_dir / (f"fusion_sigma{sigma}" if sigma else "fusion") + ) fusion_output_dir.mkdir(parents=True, exist_ok=True) print(f"[EFAST] Starting fusion: {site_name} ({lat:.6f}, {lon:.6f}), {season}") @@ -101,13 +107,19 @@ def run_efast_itb( cleaning_strategy="aggressive", sigma=None, date_range=None, + *, + s2_output_dir=None, + s3_output_dir=None, + fusion_output_dir=None, ): lat, lon = site_position datetime_range = date_range or f"{season}-01-01/{season}-12-31" efast_base_dir = _get_itb_base_dir(season, site_name, cleaning_strategy) - s2_output_dir = efast_base_dir / "s2" - s3_output_dir = efast_base_dir / "s3" - fusion_output_dir = efast_base_dir / (f"fusion_sigma{sigma}" if sigma else "fusion") + s2_output_dir = s2_output_dir or (efast_base_dir / "s2") + s3_output_dir = s3_output_dir or (efast_base_dir / "s3") + fusion_output_dir = fusion_output_dir or ( + efast_base_dir / (f"fusion_sigma{sigma}" if sigma else "fusion") + ) fusion_output_dir.mkdir(parents=True, exist_ok=True) print(f"[EFAST-ITB] Fusion GCC: {site_name} ({lat:.6f}, {lon:.6f}), {season}") efast = _import_efast() diff --git a/gap_validation/__init__.py b/gap_validation/__init__.py new file mode 100644 index 0000000..23493e8 --- /dev/null +++ b/gap_validation/__init__.py @@ -0,0 +1 @@ +"""Synthetic gap and withheld-S2 validation (outputs under data/.../validation/).""" diff --git a/gap_validation/__main__.py b/gap_validation/__main__.py new file mode 100644 index 0000000..c8394f9 --- /dev/null +++ b/gap_validation/__main__.py @@ -0,0 +1,4 @@ +from gap_validation.run import main + +if __name__ == "__main__": + main() diff --git a/gap_validation/calendar.py b/gap_validation/calendar.py new file mode 100644 index 0000000..4ca053b --- /dev/null +++ b/gap_validation/calendar.py @@ -0,0 +1,152 @@ +"""Gap windows and nearest S2 acquisition (manifest inputs).""" + +from __future__ import annotations + +import json +import re +from datetime import date, datetime, timedelta +from pathlib import Path + +from phenology_timesat import phenocam_phenology_path + +REFL_DATE_RE = re.compile(r"S2A_MSIL2A_(\d{8})_REFL\.tif$") + + +def validation_dir(site_name: str, season: int) -> Path: + return Path(f"data/{site_name}/{season}/validation") + + +def phenology_midpoint( + site_name: str, season: int, phenology_path: Path | None = None +) -> date: + """Pick fusion gap midpoint: green-up if in season, else green-down, else July 1.""" + path = phenology_path or phenocam_phenology_path(site_name, season) + y0, y1 = date(season, 1, 1), date(season, 12, 31) + fallback = date(season, 7, 1) + if not path.is_file(): + return fallback + try: + rec = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return fallback + up_s = rec.get("green_up_50pct_date") + dn_s = rec.get("green_down_50pct_date") + + def _parse(s) -> date | None: + if not s or not isinstance(s, str): + return None + try: + d = datetime.strptime(s[:10], "%Y-%m-%d").date() + except ValueError: + return None + return d if y0 <= d <= y1 else None + + up, dn = _parse(up_s), _parse(dn_s) + if up: + return up + if dn: + return dn + return fallback + + +def centered_window(mid: date, gap_days: int, season: int) -> tuple[date, date]: + """[start, end] inclusive, gap_days wide, clamped to calendar year.""" + half = gap_days // 2 + start = mid - timedelta(days=half) + end = mid + timedelta(days=gap_days - 1 - half) + y0, y1 = date(season, 1, 1), date(season, 12, 31) + if start < y0: + end = min(y1, end + (y0 - start)) + start = y0 + if end > y1: + start = max(y0, start - (end - y1)) + end = y1 + return start, end + + +def list_s2_refl_dates(prepared_s2: Path) -> list[tuple[date, str]]: + """Return sorted (acquisition_date, filename) for *REFL.tif.""" + out: list[tuple[date, str]] = [] + if not prepared_s2.is_dir(): + return out + for p in sorted(prepared_s2.glob("*REFL.tif")): + m = REFL_DATE_RE.search(p.name) + if not m: + continue + d = datetime.strptime(m.group(1), "%Y%m%d").date() + out.append((d, p.name)) + out.sort(key=lambda x: x[0]) + return out + + +def nearest_s2_acquisition( + prediction: date, pairs: list[tuple[date, str]] +) -> tuple[date, str] | None: + if not pairs: + return None + best = min(pairs, key=lambda t: abs((t[0] - prediction).days)) + return best + + +def build_manifest_entries( + site_name: str, + season: int, + gap_lengths: tuple[int, ...] = (15, 30, 60, 90), + s2_calendar_strategy: str = "aggressive", +) -> list[dict]: + """One entry per gap length: window, prediction=midpoint, withheld = nearest S2 to midpoint.""" + mid = phenology_midpoint(site_name, season) + prepared_s2 = Path(f"data/{site_name}/{season}/prepared_{s2_calendar_strategy}/s2") + pairs = list_s2_refl_dates(prepared_s2) + entries = [] + for gap_days in gap_lengths: + w0, w1 = centered_window(mid, gap_days, season) + prediction = mid + ns = nearest_s2_acquisition(prediction, pairs) + if ns is None: + withheld_date = None + withheld_filename = None + else: + withheld_date, withheld_filename = ns[0].isoformat(), ns[1] + entries.append( + { + "gap_days": gap_days, + "midpoint_rule": "green_up_50pct else green_down_50pct else July01", + "midpoint_date": mid.isoformat(), + "window_start": w0.isoformat(), + "window_end": w1.isoformat(), + "prediction_date": prediction.isoformat(), + "withheld_s2_date": withheld_date, + "withheld_s2_filename": withheld_filename, + } + ) + return entries + + +def write_manifest( + site_name: str, + season: int, + site_position: tuple[float, float], + s2_calendar_strategy: str = "aggressive", +) -> Path: + out_dir = validation_dir(site_name, season) + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / "gap_manifest.json" + payload = { + "site_name": site_name, + "season": season, + "site_position_lat_lon": list(site_position), + "s2_calendar_strategy": s2_calendar_strategy, + "entries": build_manifest_entries( + site_name, season, s2_calendar_strategy=s2_calendar_strategy + ), + } + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return path + + +def load_manifest(site_name: str, season: int) -> dict: + path = validation_dir(site_name, season) / "gap_manifest.json" + if not path.is_file(): + raise FileNotFoundError(f"Missing manifest: {path}") + return json.loads(path.read_text(encoding="utf-8")) diff --git a/gap_validation/fusion_masked.py b/gap_validation/fusion_masked.py new file mode 100644 index 0000000..df732bc --- /dev/null +++ b/gap_validation/fusion_masked.py @@ -0,0 +1,113 @@ +"""EFAST with symlinked S2 dir (withhold one acquisition); outputs under validation/.""" + +from __future__ import annotations + +from pathlib import Path +from tempfile import TemporaryDirectory + +from fusion import run_efast, run_efast_itb +from preparation import _get_base_dir, _get_itb_base_dir + +from gap_validation.s2_mask_dir import build_masked_s2_dir_bti, build_masked_s2_dir_itb + + +def prepared_s3_dir(season: int, site_name: str, strategy: str) -> Path: + return _get_base_dir(season, site_name, strategy) / "s3" + + +def validation_fusion_dir( + site_name: str, + season: int, + gap_days: int, + strategy: str, + sigma: int | None, + mode: str, +) -> Path: + """``data/.../validation/fusion/gap_{n}/{strategy}_sigma{20|30}_{bti|itb}/``.""" + sig = 30 if sigma == 30 else 20 + return ( + Path(f"data/{site_name}/{season}/validation") + / "fusion" + / f"gap_{gap_days}" + / f"{strategy}_sigma{sig}_{mode}" + ) + + +def run_masked_fusion_one_date( + season: int, + site_position: tuple[float, float], + site_name: str, + strategy: str, + sigma: int | None, + mode: str, + prediction_date_iso: str, + withheld_yyyymmdd: str, + fusion_output_dir: Path, +) -> Path: + """Build temp masked S2 dir, run EFAST for ``prediction_date_iso`` only; return output dir.""" + fusion_output_dir.mkdir(parents=True, exist_ok=True) + date_range = f"{prediction_date_iso[:10]}/{prediction_date_iso[:10]}" + s3_dir = prepared_s3_dir(season, site_name, strategy) + + with TemporaryDirectory(prefix="gapval_s2_") as tmp: + tmp_s2 = Path(tmp) / "s2" + if mode == "bti": + prep_s2 = _get_base_dir(season, site_name, strategy) / "s2" + build_masked_s2_dir_bti(prep_s2, withheld_yyyymmdd, tmp_s2) + run_efast( + season, + site_position, + site_name, + cleaning_strategy=strategy, + sigma=sigma, + date_range=date_range, + s2_output_dir=tmp_s2, + s3_output_dir=s3_dir, + fusion_output_dir=fusion_output_dir, + ) + elif mode == "itb": + prep_s2 = _get_itb_base_dir(season, site_name, strategy) / "s2" + s3_itb = _get_itb_base_dir(season, site_name, strategy) / "s3" + build_masked_s2_dir_itb(prep_s2, withheld_yyyymmdd, tmp_s2) + run_efast_itb( + season, + site_position, + site_name, + cleaning_strategy=strategy, + sigma=sigma, + date_range=date_range, + s2_output_dir=tmp_s2, + s3_output_dir=s3_itb, + fusion_output_dir=fusion_output_dir, + ) + else: + raise ValueError(f"mode must be bti or itb, got {mode!r}") + + return fusion_output_dir + + +def production_fusion_path( + season: int, + site_name: str, + strategy: str, + sigma: int | None, + mode: str, + yyyymmdd: str, +) -> Path: + """Single-date fused raster from the normal prepared tree (no-gap baseline).""" + if mode == "bti": + base = _get_base_dir(season, site_name, strategy) + sub = f"fusion_sigma{sigma}" if sigma else "fusion" + return base / sub / f"REFL_{yyyymmdd}.tif" + base = _get_itb_base_dir(season, site_name, strategy) + sub = f"fusion_sigma{sigma}" if sigma else "fusion" + return base / sub / f"GCC_{yyyymmdd}.tif" + + +def withheld_s2_refl_path( + season: int, site_name: str, strategy: str, withheld_filename: str | None +) -> Path | None: + if not withheld_filename: + return None + p = _get_base_dir(season, site_name, strategy) / "s2" / withheld_filename + return p if p.is_file() else None diff --git a/gap_validation/run.py b/gap_validation/run.py new file mode 100644 index 0000000..583bb65 --- /dev/null +++ b/gap_validation/run.py @@ -0,0 +1,290 @@ +"""Tier-2 gap validation CLI: manifest, masked EFAST, spatial ``nse_s2``, Whittaker crossover.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +from gap_validation.calendar import load_manifest, validation_dir, write_manifest +from gap_validation.fusion_masked import ( + production_fusion_path, + run_masked_fusion_one_date, + validation_fusion_dir, + withheld_s2_refl_path, +) +from gap_validation.spatial_metrics import evaluate_gap_vs_withheld +from gap_validation.whittaker_compare import first_gap_where_fusion_below_whittaker + + +def _ymd_from_iso(iso_d: str) -> str: + return datetime.strptime(iso_d[:10], "%Y-%m-%d").strftime("%Y%m%d") + + +def _yyyymmdd_from_withheld_filename(fn: str) -> str | None: + for part in fn.replace(".tif", "").split("_"): + if len(part) == 8 and part.isdigit(): + return part + return None + + +def _withheld_iso(entry: dict) -> str | None: + d = entry.get("withheld_s2_date") + if isinstance(d, str) and len(d) >= 10: + return d[:10] + fn = entry.get("withheld_s2_filename") + if not fn or not isinstance(fn, str): + return None + ymd = _yyyymmdd_from_withheld_filename(fn) + if not ymd: + return None + return datetime.strptime(ymd, "%Y%m%d").date().isoformat() + + +def _fused_file(fusion_dir: Path, mode: str, ymd: str) -> Path: + stem = "REFL" if mode == "bti" else "GCC" + return fusion_dir / f"{stem}_{ymd}.tif" + + +def _scenario_key(strategy: str, sigma: int | None, mode: str) -> str: + sig = 30 if sigma == 30 else 20 + return f"{strategy}_sigma{sig}_{mode}" + + +def _git_rev() -> str | None: + try: + return subprocess.check_output( + ["git", "rev-parse", "HEAD"], + cwd=Path(__file__).resolve().parent.parent, + text=True, + ).strip() + except (OSError, subprocess.CalledProcessError): + return None + + +def run_validation( + site_name: str, + season: int, + site_position: tuple[float, float], + strategy: str, + sigma: int | None, + mode: str, + *, + skip_manifest: bool, + skip_fusion: bool, + write_manifest_only: bool, + gap_days_filter: list[int] | None, + s2_calendar_strategy: str, +) -> Path: + base = Path(f"data/{site_name}/{season}") + vdir = validation_dir(site_name, season) + vdir.mkdir(parents=True, exist_ok=True) + + if not skip_manifest: + write_manifest( + site_name, season, site_position, s2_calendar_strategy=s2_calendar_strategy + ) + if write_manifest_only: + return vdir / "gap_manifest.json" + + manifest = load_manifest(site_name, season) + entries = manifest["entries"] + if gap_days_filter: + entries = [e for e in entries if e.get("gap_days") in gap_days_filter] + + results: list[dict] = [] + for entry in entries: + gap_days = entry["gap_days"] + pred = entry["prediction_date"] + fn = entry.get("withheld_s2_filename") + if not fn: + results.append( + { + "gap_days": gap_days, + "error": "no_withheld_s2_filename", + "entry": entry, + } + ) + continue + ymd = _ymd_from_iso(pred) + wh_ymd = _yyyymmdd_from_withheld_filename(fn) + if not wh_ymd: + results.append( + { + "gap_days": gap_days, + "error": "could_not_parse_withheld_yyyymmdd", + "withheld_s2_filename": fn, + } + ) + continue + withheld_iso = ( + _withheld_iso(entry) or f"{wh_ymd[:4]}-{wh_ymd[4:6]}-{wh_ymd[6:8]}" + ) + + fusion_out = validation_fusion_dir( + site_name, season, gap_days, strategy, sigma, mode + ) + if not skip_fusion: + run_masked_fusion_one_date( + season, + site_position, + site_name, + strategy, + sigma, + mode, + pred, + wh_ymd, + fusion_out, + ) + + fused_gap = _fused_file(fusion_out, mode, ymd) + prod = production_fusion_path(season, site_name, strategy, sigma, mode, ymd) + wh_path = withheld_s2_refl_path(season, site_name, strategy, fn) + if wh_path is None or not fused_gap.is_file(): + results.append( + { + "gap_days": gap_days, + "prediction_date": pred, + "withheld_s2_filename": fn, + "scenario": { + "strategy": strategy, + "sigma": 30 if sigma == 30 else 20, + "mode": mode, + }, + "error": "missing_withheld_refl_or_fused_gap", + "fused_gap_path": str(fused_gap), + } + ) + continue + + spatial = evaluate_gap_vs_withheld( + wh_path, + fused_gap, + prod if prod.is_file() else None, + mode, + whittaker_context=(base, strategy, pred, withheld_iso), + ) + fusion_nse = (spatial.get("gap") or {}).get("nse_s2") + wh_nse = (spatial.get("whittaker") or {}).get("nse_s2") + results.append( + { + "gap_days": gap_days, + "prediction_date": pred, + "withheld_s2_filename": fn, + "scenario": { + "strategy": strategy, + "sigma": 30 if sigma == 30 else 20, + "mode": mode, + }, + "paths": { + "fused_gap": str(fused_gap), + "fused_no_gap": str(prod) if prod.is_file() else None, + "withheld_s2_refl": str(wh_path), + }, + "spatial": spatial, + "whittaker_crossover_row": { + "gap_days": gap_days, + "nse_s2_fusion": fusion_nse, + "nse_s2_whittaker": wh_nse, + }, + } + ) + + scenario = _scenario_key(strategy, sigma, mode) + crossover_rows = [ + r["whittaker_crossover_row"] + for r in results + if isinstance(r.get("whittaker_crossover_row"), dict) + ] + summary = { + "site_name": site_name, + "season": season, + "scenario": scenario, + "command_line": sys.argv, + "git_commit": _git_rev(), + "manifest": str(vdir / "gap_manifest.json"), + "results": results, + "whittaker_crossover": { + scenario: { + "metric": "nse_s2_spatial_vs_withheld_s2_gcc", + "whittaker_definition": ( + "Whittaker λ=400 d² on cloud-screened S2 GCC from s2_preselection.json; " + "withheld acquisition removed from the fit; prediction is a spatially constant " + "field at the smoothed GCC(prediction_date), compared to withheld S2 GCC on the " + "same valid mask as fusion (aligned with baseline.s2_whittaker_lambda400 spirit)." + ), + "first_gap_days_fusion_nse_below_whittaker": first_gap_where_fusion_below_whittaker( + crossover_rows, + fusion_key="nse_s2_fusion", + whittaker_key="nse_s2_whittaker", + ), + "by_gap": crossover_rows, + } + }, + } + out_path = vdir / "gap_validation_summary.json" + out_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + return out_path + + +def main() -> None: + ap = argparse.ArgumentParser( + description="Tier-2 withheld-S2 gap validation (outputs under data/.../validation/)." + ) + ap.add_argument("--site", required=True) + ap.add_argument("--season", type=int, required=True) + ap.add_argument("--lat", type=float, required=True) + ap.add_argument("--lon", type=float, required=True) + ap.add_argument( + "--strategy", default="aggressive", choices=["aggressive", "nonaggressive"] + ) + ap.add_argument("--sigma", type=int, default=20, choices=[20, 30]) + ap.add_argument("--mode", default="bti", choices=["bti", "itb"]) + ap.add_argument( + "--gap-days", + type=int, + action="append", + metavar="N", + help="Restrict to gap length(s); repeatable (default: all manifest lengths).", + ) + ap.add_argument("--skip-manifest", action="store_true") + ap.add_argument( + "--skip-fusion", + action="store_true", + help="Reuse existing validation fusion rasters.", + ) + ap.add_argument( + "--write-manifest-only", + action="store_true", + help="Write gap_manifest.json and exit (no EFAST).", + ) + ap.add_argument( + "--s2-calendar-strategy", + default="aggressive", + choices=["aggressive", "nonaggressive"], + help="Which prepared_*/s2 tree is used to pick nearest S2 for withholding.", + ) + args = ap.parse_args() + sigma_kw = 30 if args.sigma == 30 else None + site_position = (args.lat, args.lon) + out = run_validation( + args.site, + args.season, + site_position, + args.strategy, + sigma_kw, + args.mode, + skip_manifest=args.skip_manifest, + skip_fusion=args.skip_fusion, + write_manifest_only=args.write_manifest_only, + gap_days_filter=args.gap_days, + s2_calendar_strategy=args.s2_calendar_strategy, + ) + print(out) + + +if __name__ == "__main__": + main() diff --git a/gap_validation/s2_mask_dir.py b/gap_validation/s2_mask_dir.py new file mode 100644 index 0000000..6112dcf --- /dev/null +++ b/gap_validation/s2_mask_dir.py @@ -0,0 +1,51 @@ +"""Symlink prepared S2 into a temp dir, omitting one acquisition (REFL + DIST_CLOUD).""" + +from __future__ import annotations + +import re +from pathlib import Path + +# Acquisition calendar day in prepared S2 names (BtI REFL/DIST; ItB GCC/DIST). +S2_PREP_DATE_RE = re.compile(r"_(\d{8})_(?:REFL|GCC|DIST_CLOUD)\.tif$", re.IGNORECASE) + + +def yyyymmdd_in_name(name: str) -> str | None: + m = S2_PREP_DATE_RE.search(name) + return m.group(1) if m else None + + +def build_masked_s2_dir( + prepared_s2: Path, withheld_yyyymmdd: str, dest: Path, patterns: tuple[str, ...] +) -> int: + """Symlink all files matching ``patterns`` except the withheld acquisition day.""" + dest.mkdir(parents=True, exist_ok=True) + n = 0 + for pattern in patterns: + for src in sorted(prepared_s2.glob(pattern)): + if not src.is_file() and not src.is_symlink(): + continue + y = yyyymmdd_in_name(src.name) + if y == withheld_yyyymmdd: + continue + link = dest / src.name + if link.exists() or link.is_symlink(): + link.unlink() + link.symlink_to(src.resolve()) + n += 1 + return n + + +def build_masked_s2_dir_bti( + prepared_s2: Path, withheld_yyyymmdd: str, dest: Path +) -> int: + return build_masked_s2_dir( + prepared_s2, withheld_yyyymmdd, dest, ("*REFL.tif", "*DIST_CLOUD.tif") + ) + + +def build_masked_s2_dir_itb( + prepared_s2: Path, withheld_yyyymmdd: str, dest: Path +) -> int: + return build_masked_s2_dir( + prepared_s2, withheld_yyyymmdd, dest, ("*GCC.tif", "*DIST_CLOUD.tif") + ) diff --git a/gap_validation/spatial_metrics.py b/gap_validation/spatial_metrics.py new file mode 100644 index 0000000..64e126a --- /dev/null +++ b/gap_validation/spatial_metrics.py @@ -0,0 +1,187 @@ +"""Per-pixel GCC vs withheld S2; NSE (nse_s2); no-gap baseline; deltas.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import rasterio +from rasterio.warp import reproject, Resampling +from scipy.stats import pearsonr + +# Match postprocessing valid mask on reflectance (METH / postprocessing.py). +VALID_REFL_THRESHOLD = 0.001 + + +def _gcc_from_rgb(blue: np.ndarray, green: np.ndarray, red: np.ndarray) -> np.ndarray: + t = red.astype(np.float64) + green.astype(np.float64) + blue.astype(np.float64) + out = np.full_like(blue, np.nan, dtype=np.float64) + m = ( + np.isfinite(t) + & (t > 0) + & np.isfinite(blue) + & np.isfinite(green) + & np.isfinite(red) + ) + out[m] = green[m].astype(np.float64) / t[m] + return out.astype(np.float32) + + +def read_fused_gcc(fusion_path: Path) -> tuple[np.ndarray, dict]: + """Fused GCC: BtI from 4-band REFL or ItB single-band GCC.""" + with rasterio.open(fusion_path) as src: + if src.count >= 4: + b = src.read(1).astype(np.float32) + g = src.read(2).astype(np.float32) + r = src.read(3).astype(np.float32) + gcc = _gcc_from_rgb(b, g, r) + else: + gcc = src.read(1).astype(np.float32) + prof = src.profile.copy() + return gcc, prof + + +def warp_refl_bands_to_grid( + refl_path: Path, + height: int, + width: int, + transform, + crs, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Resample S2 REFL blue/green/red to fusion grid (bilinear).""" + with rasterio.open(refl_path) as src: + b = np.empty((height, width), dtype=np.float32) + g = np.empty((height, width), dtype=np.float32) + r = np.empty((height, width), dtype=np.float32) + for i, dst in enumerate((b, g, r), start=1): + reproject( + source=rasterio.band(src, i), + destination=dst, + src_transform=src.transform, + src_crs=src.crs, + dst_transform=transform, + dst_crs=crs, + resampling=Resampling.bilinear, + ) + return b, g, r + + +def valid_mask_fused(fusion_path: Path, mode: str) -> np.ndarray: + """Valid pixels: BtI uses REFL-style mask; ItB uses single-band GCC (postprocessing ItB).""" + with rasterio.open(fusion_path) as src: + if mode == "itb" or src.count < 4: + d = src.read(1).astype(np.float32) + return np.isfinite(d) & (d > VALID_REFL_THRESHOLD) + stacks = src.read().astype(np.float32) + ok = np.isfinite(stacks).all(axis=0) & ( + np.nanmax(stacks, axis=0) > VALID_REFL_THRESHOLD + ) + return ok + + +def spatial_scores( + y_true_gcc: np.ndarray, + y_pred_gcc: np.ndarray, + mask: np.ndarray, +) -> dict: + """RMSE, MAE, mean bias, Pearson r, nse_s2 (Nash–Sutcliffe vs spatial truth).""" + yt = y_true_gcc[mask].astype(np.float64).ravel() + yp = y_pred_gcc[mask].astype(np.float64).ravel() + n = int(yt.size) + if n < 2: + return {"n_pixels": n} + mean_t = float(np.mean(yt)) + rmse = float(np.sqrt(np.mean((yt - yp) ** 2))) + mae = float(np.mean(np.abs(yt - yp))) + bias = float(np.mean(yp - yt)) + den = float(np.sum((yt - mean_t) ** 2)) + nse_s2 = float(1.0 - np.sum((yt - yp) ** 2) / den) if den > 0 else None + r = None + if np.std(yt) > 0 and np.std(yp) > 0: + r = float(pearsonr(yt, yp)[0]) + return { + "n_pixels": n, + "rmse": rmse, + "mae": mae, + "mean_bias": bias, + "pearson_r": r, + "nse_s2": nse_s2, + } + + +def withheld_gcc_on_fusion_grid( + withheld_refl_path: Path, fused_path: Path +) -> tuple[np.ndarray, np.ndarray, dict]: + """``y_true`` GCC (withheld S2) and ``y_pred`` GCC from ``fused_path``, same grid.""" + yp, prof = read_fused_gcc(fused_path) + h, w = yp.shape + b, g, r = warp_refl_bands_to_grid( + withheld_refl_path, h, w, prof["transform"], prof["crs"] + ) + yt = _gcc_from_rgb(b, g, r) + return yt, yp, prof + + +def common_valid_mask( + yt: np.ndarray, + y_gap: np.ndarray, + y_nogap: np.ndarray | None, + fused_gap_path: Path, + mode: str, +) -> np.ndarray: + """Shared finite mask: truth GCC, gap/nogap preds, and fusion valid-data rules.""" + m = ( + valid_mask_fused(fused_gap_path, mode) + & np.isfinite(yt) + & np.isfinite(y_gap) + & (yt > VALID_REFL_THRESHOLD) + & (y_gap > VALID_REFL_THRESHOLD) + ) + if y_nogap is not None: + m &= np.isfinite(y_nogap) & (y_nogap > VALID_REFL_THRESHOLD) + return m + + +def evaluate_gap_vs_withheld( + withheld_refl_path: Path, + fused_gap_path: Path, + fused_nogap_path: Path | None, + mode: str, + *, + whittaker_context: tuple[Path, str, str, str] | None = None, +) -> dict: + """Spatial metrics for gap and no-gap; deltas; optional Whittaker constant-field vs same mask. + + ``delta_rmse`` = RMSE_gap − RMSE_no_gap; ``delta_nse`` = NSE_no_gap − NSE_gap (higher gap loss → positive delta_nse). + """ + yt, y_gap, _prof = withheld_gcc_on_fusion_grid(withheld_refl_path, fused_gap_path) + y_nogap = None + if fused_nogap_path is not None and fused_nogap_path.is_file(): + y_nogap, _ = read_fused_gcc(fused_nogap_path) + mask = common_valid_mask(yt, y_gap, y_nogap, fused_gap_path, mode) + out: dict = {"gap": spatial_scores(yt, y_gap, mask)} + if y_nogap is not None: + out["no_gap"] = spatial_scores(yt, y_nogap, mask) + g, ng = out["gap"], out["no_gap"] + if g.get("rmse") is not None and ng.get("rmse") is not None: + out["delta_rmse"] = float(g["rmse"] - ng["rmse"]) + if g.get("nse_s2") is not None and ng.get("nse_s2") is not None: + out["delta_nse"] = float(ng["nse_s2"] - g["nse_s2"]) + if whittaker_context is not None: + from gap_validation.whittaker_compare import whittaker_gcc_on_gap_masked_series + + base, strategy, prediction_iso, withheld_iso = whittaker_context + wgcc = whittaker_gcc_on_gap_masked_series( + base, strategy, prediction_iso, withheld_iso + ) + if wgcc is not None: + out["whittaker"] = constant_field_scores(yt, float(wgcc), mask) + return out + + +def constant_field_scores( + y_true_gcc: np.ndarray, scalar: float, mask: np.ndarray +) -> dict: + """NSE / RMSE when prediction is a spatially constant Whittaker value (same mask as fusion).""" + yp = np.full_like(y_true_gcc, scalar, dtype=np.float32) + return spatial_scores(y_true_gcc, yp, mask) diff --git a/gap_validation/whittaker_compare.py b/gap_validation/whittaker_compare.py new file mode 100644 index 0000000..cb23aee --- /dev/null +++ b/gap_validation/whittaker_compare.py @@ -0,0 +1,64 @@ +"""Whittaker S2 GCC (λ=400 d²) as a spatial constant vs withheld S2 GCC; crossover vs fusion nse_s2.""" + +from __future__ import annotations + +from pathlib import Path + +from metrics_stats import ( + WHITTAKER_LAMBDA_DAYS_SQ, + _norm_date_key, + _s2_gcc_series_from_preselection, + _whittaker_smooth_dict, +) + + +def whittaker_gcc_on_gap_masked_series( + base: Path, + strategy: str, + prediction_iso: str, + withheld_iso: str, + lam: float = WHITTAKER_LAMBDA_DAYS_SQ, +) -> float | None: + """Whittaker smooth on cloud-screened S2 GCC **excluding** the withheld acquisition day. + + Comparator aligned with ``baseline.s2_whittaker_lambda400`` in ``metrics_stats`` (same λ, + same preselection GCC), but the withheld date is removed so the smoother does not see + the target acquisition. Value at ``prediction_iso`` (YYYY-MM-DD) is returned. + """ + pred_k = _norm_date_key(prediction_iso) + wh_k = _norm_date_key(withheld_iso) + if not pred_k or not wh_k: + return None + all_gcc, flags = _s2_gcc_series_from_preselection(base) + if not all_gcc: + return None + idx = 0 if strategy == "aggressive" else 1 + kept = sorted( + (d, g) + for d, g in all_gcc.items() + if d in flags and not flags[d][idx] and _norm_date_key(d) != wh_k + ) + if len(kept) < 2: + return None + obs_d, obs_v = zip(*kept) + smooth = _whittaker_smooth_dict(obs_d, obs_v, lam) + return smooth.get(pred_k) + + +def first_gap_where_fusion_below_whittaker( + rows: list[dict], + *, + fusion_key: str = "nse_s2", + whittaker_key: str = "nse_s2", +) -> int | None: + """Smallest ``gap_days`` where fusion[metric] < whittaker[metric] (strict).""" + eligible = [ + r + for r in rows + if r.get(fusion_key) is not None and r.get(whittaker_key) is not None + ] + eligible.sort(key=lambda r: r["gap_days"]) + for r in eligible: + if r[fusion_key] < r[whittaker_key]: + return int(r["gap_days"]) + return None diff --git a/metrics_stats.py b/metrics_stats.py index 865a702..1e49e77 100644 --- a/metrics_stats.py +++ b/metrics_stats.py @@ -64,7 +64,7 @@ def pearson_correlation(y_true, y_pred): def r_squared(y_true, y_pred): - """Calculate coefficient of determination R².""" + """Generalized R² vs predicting mean(y_true); can be negative. Same formula as ``nse`` with the same arguments; not Pearson r squared.""" if len(y_true) < 2 or np.std(y_true) == 0: return None ss_res = np.sum((y_true - y_pred) ** 2) diff --git a/webapp/fusion.html b/webapp/fusion.html index 205cba6..a588e0c 100644 --- a/webapp/fusion.html +++ b/webapp/fusion.html @@ -44,6 +44,7 @@ Fusion Postprocessed Metrics + Gap validation Phenology

Innsbruck

diff --git a/webapp/gap_validation.html b/webapp/gap_validation.html new file mode 100644 index 0000000..f050ff6 --- /dev/null +++ b/webapp/gap_validation.html @@ -0,0 +1,283 @@ + + + + + Gap validation + + + +
+ +

Gap validation

+
+ + + + +
+
+
+ + + diff --git a/webapp/index.html b/webapp/index.html index 784e7a0..40ed804 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -55,6 +55,7 @@ Fusion Postprocessed Metrics + Gap validation Phenology
@@ -136,7 +137,7 @@
Metrics vs PhenoCam (fusion scenarios)

- R² (variance explained), nRMSE (RMSE normalised by PhenoCam σ), NSE_PC (Nash–Sutcliffe vs PhenoCam). ΔNSE_PC = NSE_PC(σ20)−NSE_PC(σ30): positive → σ20 better; negative → σ30 better. Mean residual (fused−PhenoCam): positive → fusion high vs PhenoCam on average; negative → low; compare BtI vs ItB in the same row (closer to 0 = less mean bias). Tables at the top when metrics.json has derived (regenerate with metrics_stats.py / run.py). + R² vs mean (JSON r_squared): generalized R² vs predicting mean PhenoCam each day — same numeric value as NSE_PC, not (Pearson r)²; can be negative. nRMSE (RMSE / mean PhenoCam). ΔNSE_PC = NSE_PC(σ20)−NSE_PC(σ30): positive → σ20 better; negative → σ30 better. Mean residual (fused−PhenoCam): positive → fusion high vs PhenoCam on average; negative → low; compare BtI vs ItB in the same row (closer to 0 = less mean bias). Tables at the top when metrics.json has derived (regenerate with metrics_stats.py / run.py).

@@ -642,7 +643,7 @@ html += "

Mean residual (fused − PhenoCam): BtI vs ItB

"; html += - "

Each cell: mean(fused−PhenoCam) on matched dates. + overestimates, underestimates; ~0 little mean bias (see R²/MAE for overall fit). Same row: column closer to 0 → less systematic offset vs PhenoCam (RQ1.1).

"; + "

Each cell: mean(fused−PhenoCam) on matched dates. + overestimates, underestimates; ~0 little mean bias (see R² vs mean / nRMSE / NSE_PC for overall fit). Same row: column closer to 0 → less systematic offset vs PhenoCam (RQ1.1).

"; html += ""; for (const row of d.bti_vs_itb_mean_residual) { @@ -654,7 +655,7 @@ const scenarios = ["aggressive_sigma20", "aggressive_sigma30", "nonaggressive_sigma20", "nonaggressive_sigma30"]; const scenarioNames = ["Aggressive σ20", "Aggressive σ30", "Non-aggressive σ20", "Non-aggressive σ30"]; const metrics = ["r_squared", "nrmse", "nse_pc"]; - const metricLabels = { r_squared: "R²", nrmse: "nRMSE", nse_pc: "NSE_PC" }; + const metricLabels = { r_squared: "R² vs mean", nrmse: "nRMSE", nse_pc: "NSE_PC" }; html += "
StrategyσBtIItB
"; html += ""; diff --git a/webapp/metrics.html b/webapp/metrics.html index e710b65..25f8780 100644 --- a/webapp/metrics.html +++ b/webapp/metrics.html @@ -55,6 +55,7 @@ FusionPostprocessedMetrics + Gap validationPhenology

Metrics

@@ -70,7 +71,7 @@ /** Shown in the UI; pearson_r, rmse, mae, n_samples remain in metrics.json only. */ const DISPLAY_METRIC_COLS = ["r_squared", "nrmse", "nse_pc"]; const DISPLAY_METRIC_LABELS = { - r_squared: "R²", + r_squared: "R² vs mean", nrmse: "nRMSE", nse_pc: "NSE_PC", }; @@ -229,9 +230,9 @@ How to read
  1. All scores are satellite or fusion GCC vs PhenoCam GCC at the site 3×3 window, same calendar days only. Extra stats: metrics.json.
  2. -
  3. , NSE_PC: higher = better. nRMSE: lower = better.
  4. +
  5. R² vs mean and NSE_PC are the same value (1 − SSres/SStot vs predicting mean PhenoCam each day); not (Pearson r)²; can be negative. Higher = better. nRMSE: lower = better.
  6. Fusion: same row number in BtI and in ItB = same screening + same σ — compare left/right. Down one block = change screening or σ.
  7. -
  8. Mean resid. (if present): mean(fused − PhenoCam). Sign = average bias; use R² / nRMSE / NSE_PC for overall fit.
  9. +
  10. Mean resid. (if present): mean(fused − PhenoCam). Sign = average bias; use R² vs mean / nRMSE / NSE_PC for overall fit.
  11. Summaries: ΔNSE_PC = NSE at σ20 minus NSE at σ30 (+ means σ20 wins). Paired table: closer to 0 = less mean bias.
`; @@ -245,6 +246,7 @@
  • ItB: GCC on S2 and S3, then fuse GCC.
  • Scenario: screening (aggressive / nonaggressive) × σ (20 / 30 days).
  • Phenology — PhenoCam SOS/EOS (TIMESAT).
  • +
  • R² vs mean — coefficient of determination vs a constant mean(PhenoCam) baseline; JSON key r_squared; duplicates nse_pc. Not (Pearson r)².
  • metrics.json — also Pearson r, RMSE, MAE, n_samples.
  • `; @@ -274,7 +276,7 @@ const baselineTbl = baselineTable(data.baseline); if (baselineTbl) { html += `

    Baselines (vs PhenoCam)

    `; - html += `

    Same columns as fusion (vs PhenoCam). Higher R² / NSE_PC, lower nRMSE = better. S3 = coarse-only; Whittaker = smoothed S2-only.

    `; + html += `

    Same columns as fusion (vs PhenoCam). Higher R² vs mean / NSE_PC, lower nRMSE = better. S3 = coarse-only; Whittaker = smoothed S2-only.

    `; html += baselineTbl; } diff --git a/webapp/phenology.html b/webapp/phenology.html index 2b63c43..fd0046e 100644 --- a/webapp/phenology.html +++ b/webapp/phenology.html @@ -34,6 +34,7 @@ FusionPostprocessedMetrics + Gap validationPhenology

    PhenoCam phenology (50% amplitude)

    diff --git a/webapp/postprocessed.html b/webapp/postprocessed.html index f38ad70..5c513cc 100644 --- a/webapp/postprocessed.html +++ b/webapp/postprocessed.html @@ -44,6 +44,7 @@ FusionPostprocessedMetrics + Gap validationPhenology

    Innsbruck

    diff --git a/webapp/prepared.html b/webapp/prepared.html index e8336d9..f6896ac 100644 --- a/webapp/prepared.html +++ b/webapp/prepared.html @@ -44,6 +44,7 @@ FusionPostprocessedMetrics + Gap validationPhenology

    Innsbruck

    diff --git a/webapp/preselection.html b/webapp/preselection.html index fdb171e..9503b00 100644 --- a/webapp/preselection.html +++ b/webapp/preselection.html @@ -43,6 +43,7 @@ FusionPostprocessedMetrics + Gap validationPhenology

    Innsbruck