Renaming.

This commit is contained in:
Felix Delattre 2026-02-20 21:57:42 +01:00
parent f9da4aef7d
commit 3919b8e871
12 changed files with 953 additions and 203 deletions

View file

@ -1,3 +1,4 @@
"""PhenoCam acquisition from PhenoCam Network API."""
import csv import csv
import json import json
import requests import requests
@ -13,7 +14,7 @@ def _find_start_offset(site_name, start_dt, total_count):
"""Binary search to find approximate offset for start date.""" """Binary search to find approximate offset for start date."""
low, high = 0, total_count - 1 low, high = 0, total_count - 1
limit = 1 limit = 1
for _ in range(15): for _ in range(15):
mid = (low + high) // 2 mid = (low + high) // 2
response = requests.get( response = requests.get(
@ -25,11 +26,11 @@ def _find_start_offset(site_name, start_dt, total_count):
results = response.json().get("results", []) results = response.json().get("results", [])
if not results: if not results:
break break
mid_date_str = results[0].get("imgdate", "") mid_date_str = results[0].get("imgdate", "")
if not mid_date_str: if not mid_date_str:
break break
try: try:
mid_date = datetime.strptime(mid_date_str, "%Y-%m-%d") mid_date = datetime.strptime(mid_date_str, "%Y-%m-%d")
if mid_date < start_dt: if mid_date < start_dt:
@ -38,7 +39,7 @@ def _find_start_offset(site_name, start_dt, total_count):
high = mid high = mid
except ValueError: except ValueError:
break break
return max(0, low - 100) return max(0, low - 100)
@ -62,32 +63,32 @@ def download_phenocam(season, site_position, site_name, date_range=None):
) )
response.raise_for_status() response.raise_for_status()
total_count = response.json().get("count", 0) total_count = response.json().get("count", 0)
if total_count == 0: if total_count == 0:
print(f"[PhenoCam] No images found for site '{site_name}'") print(f"[PhenoCam] No images found for site '{site_name}'")
return return
print(f"[PhenoCam] Found {total_count} total images, estimating start offset...") print(f"[PhenoCam] Found {total_count} total images, estimating start offset...")
start_offset = _find_start_offset(site_name, start_dt, total_count) start_offset = _find_start_offset(site_name, start_dt, total_count)
url = f"{PHENOCAM_API}/middayimages/" url = f"{PHENOCAM_API}/middayimages/"
params = {"site": site_name, "offset": start_offset} params = {"site": site_name, "offset": start_offset}
print(f"[PhenoCam] Fetching image list from offset {start_offset}...") print(f"[PhenoCam] Fetching image list from offset {start_offset}...")
images = [] images = []
page = 1 page = 1
max_pages = 500 max_pages = 500
past_end_date = False past_end_date = False
while url and page <= max_pages and not past_end_date: while url and page <= max_pages and not past_end_date:
response = requests.get(url, params=params, timeout=30) response = requests.get(url, params=params, timeout=30)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
results = data.get("results", []) results = data.get("results", [])
if not results: if not results:
break break
for img in results: for img in results:
img_date_str = img.get("imgdate", "") img_date_str = img.get("imgdate", "")
if not img_date_str: if not img_date_str:
@ -101,7 +102,7 @@ def download_phenocam(season, site_position, site_name, date_range=None):
images.append(img) images.append(img)
except ValueError: except ValueError:
continue continue
if url and not past_end_date: if url and not past_end_date:
url = data.get("next") url = data.get("next")
params = None params = None
@ -120,15 +121,15 @@ def download_phenocam(season, site_position, site_name, date_range=None):
date_str = img.get("imgdate", "").replace("-", "") date_str = img.get("imgdate", "").replace("-", "")
if not date_str: if not date_str:
return None return None
filepath = output_dir / f"{date_str}.jpg" filepath = output_dir / f"{date_str}.jpg"
if filepath.exists(): if filepath.exists():
return f"Skipped {date_str}.jpg (exists)" return f"Skipped {date_str}.jpg (exists)"
img_path = img.get("imgpath") img_path = img.get("imgpath")
if not img_path: if not img_path:
return None return None
img_url = f"https://phenocam.nau.edu{img_path}" img_url = f"https://phenocam.nau.edu{img_path}"
try: try:
img_response = requests.get(img_url, timeout=30) img_response = requests.get(img_url, timeout=30)
@ -153,13 +154,13 @@ def download_phenocam_greenness(season, site_position, site_name, date_range=Non
datetime_range = date_range or f"{season}-01-01/{season}-12-31" datetime_range = date_range or f"{season}-01-01/{season}-12-31"
output_file = Path(f"data/{site_name}/{season}/raw/phenocam/timeseries.json") output_file = Path(f"data/{site_name}/{season}/raw/phenocam/timeseries.json")
output_file.parent.mkdir(parents=True, exist_ok=True) output_file.parent.mkdir(parents=True, exist_ok=True)
start_date, end_date = datetime_range.split("/") start_date, end_date = datetime_range.split("/")
start_dt = datetime.strptime(start_date, "%Y-%m-%d") start_dt = datetime.strptime(start_date, "%Y-%m-%d")
end_dt = datetime.strptime(end_date, "%Y-%m-%d") end_dt = datetime.strptime(end_date, "%Y-%m-%d")
print(f"[PhenoCam-GI] Fetching greenness-index time series: {site_name}, {season}") print(f"[PhenoCam-GI] Fetching greenness-index time series: {site_name}, {season}")
# Get ROIs for site (paginate through results) # Get ROIs for site (paginate through results)
try: try:
url = f"{PHENOCAM_API}/roilists/" url = f"{PHENOCAM_API}/roilists/"
@ -184,7 +185,7 @@ def download_phenocam_greenness(season, site_position, site_name, date_range=Non
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
print(f"[PhenoCam-GI] Error fetching ROIs: {e}") print(f"[PhenoCam-GI] Error fetching ROIs: {e}")
return return
# Fetch CSV data # Fetch CSV data
try: try:
csv_r = requests.get(csv_url, timeout=30) csv_r = requests.get(csv_url, timeout=30)
@ -207,10 +208,9 @@ def download_phenocam_greenness(season, site_position, site_name, date_range=Non
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
print(f"[PhenoCam-GI] Error fetching CSV: {e}") print(f"[PhenoCam-GI] Error fetching CSV: {e}")
return return
timeseries.sort(key=lambda x: x["date"]) timeseries.sort(key=lambda x: x["date"])
with open(output_file, "w") as f: with open(output_file, "w") as f:
json.dump(timeseries, f, indent=2) json.dump(timeseries, f, indent=2)
print(f"[PhenoCam-GI] Saved: {output_file} ({len(timeseries)} entries)")
print(f"[PhenoCam-GI] Saved: {output_file} ({len(timeseries)} entries)")

View file

@ -1,12 +1,16 @@
"""Sentinel-2-MSI acquisition from AWS Element84 Earth Search (STAC catalog)."""
import numpy as np
import rasterio import rasterio
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import requests import requests
from pathlib import Path from pathlib import Path
from rasterio.warp import transform_geom from rasterio.crs import CRS
from rasterio.warp import Resampling, calculate_default_transform, reproject, transform_geom
from rasterio.windows import from_bounds, transform as window_transform from rasterio.windows import from_bounds, transform as window_transform
from pystac_client import Client from pystac_client import Client
BBOX_SIZE = 0.011 BBOX_SIZE = 0.016
TARGET_CRS = CRS.from_epsg(32632)
def _get_bbox(lon, lat): def _get_bbox(lon, lat):
@ -128,10 +132,41 @@ def download_s2(season, site_position, site_name, date_range=None):
band_data[band_idx] = data[0] band_data[band_idx] = data[0]
if profile and len(band_data) == len(bands): if profile and len(band_data) == len(bands):
stacked = [band_data[i] for i in sorted(band_data.keys())] stacked = np.array([band_data[i] for i in sorted(band_data.keys())])
band_names = [list(bands.keys())[i] for i in sorted(band_data.keys())] band_names = [list(bands.keys())[i] for i in sorted(band_data.keys())]
viewing_angle = _extract_viewing_angle(item) viewing_angle = _extract_viewing_angle(item)
if profile["crs"] != TARGET_CRS:
src_transform = profile["transform"]
src_height, src_width = profile["height"], profile["width"]
left, bottom, right, top = rasterio.transform.array_bounds(
src_height, src_width, src_transform
)
dst_transform, dst_width, dst_height = calculate_default_transform(
profile["crs"], TARGET_CRS, src_width, src_height,
left=left, bottom=bottom, right=right, top=top,
)
reprojected = np.empty(
(len(stacked), dst_height, dst_width), dtype=stacked.dtype
)
for i in range(len(stacked)):
reproject(
source=stacked[i],
destination=reprojected[i],
src_transform=src_transform,
src_crs=profile["crs"],
dst_transform=dst_transform,
dst_crs=TARGET_CRS,
resampling=Resampling.bilinear,
)
stacked = reprojected
profile.update({
"crs": TARGET_CRS,
"transform": dst_transform,
"width": dst_width,
"height": dst_height,
})
with rasterio.open(filepath, "w", **profile) as dst: with rasterio.open(filepath, "w", **profile) as dst:
for i, data in enumerate(stacked, 1): for i, data in enumerate(stacked, 1):
dst.write(data, i) dst.write(data, i)

View file

@ -1,3 +1,4 @@
"""Sentinel-3-OLCI acquisition from Copernicus Data Space OpenEO API."""
import os import os
import time import time
from pathlib import Path from pathlib import Path

76
fusion.py Normal file
View file

@ -0,0 +1,76 @@
"""EFAST fusion: S2/S3 reflectance fusion for four scenarios."""
from pathlib import Path
from datetime import datetime, timedelta
from preselection import detect_clouds
from preparation import (
prepare_s2,
prepare_s3,
_get_base_dir,
RESOLUTION_RATIO,
)
def _import_efast():
"""Lazy import of efast to avoid import errors when not using efast functions."""
try:
import efast
return efast
except ImportError:
raise ImportError(
"efast package not found. Install with: pip install git+https://github.com/DHI-GRAS/efast.git"
)
def run_efast(season, site_position, site_name, cleaning_strategy="aggressive", sigma=None, date_range=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")
fusion_output_dir.mkdir(parents=True, exist_ok=True)
print(f"[EFAST] Starting fusion: {site_name} ({lat:.6f}, {lon:.6f}), {season}")
efast = _import_efast()
start_str, end_str = datetime_range.split("/")
start_date = datetime.strptime(start_str, "%Y-%m-%d")
end_date = datetime.strptime(end_str, "%Y-%m-%d")
current_date = start_date
while current_date <= end_date:
date_str = current_date.strftime("%Y%m%d")
output_file = fusion_output_dir / f"REFL_{date_str}.tif"
try:
kwargs = {
"product": "REFL",
"max_days": 30,
"date_position": 2,
"minimum_acquisition_importance": 0.0,
"ratio": RESOLUTION_RATIO,
}
if sigma is not None:
kwargs["sigma"] = sigma
efast.fusion(current_date, s3_output_dir, s2_output_dir, fusion_output_dir, **kwargs)
print(
f"[EFAST] Saved: {output_file}"
if output_file.exists()
else f"[EFAST] No output for {date_str} (insufficient nearby data)"
)
except Exception as e:
print(f"[EFAST] Error processing {date_str}: {e}")
current_date += timedelta(days=1)
print("[EFAST] Completed")
def run_all_efast_scenarios(season, site_position, site_name, sigma_value=30, date_range=None):
for strategy in ["aggressive", "nonaggressive"]:
detect_clouds(season, site_name, cleaning_strategy=strategy)
prepare_s2(season, site_position, site_name, cleaning_strategy=strategy, date_range=date_range)
prepare_s3(season, site_position, site_name, cleaning_strategy=strategy, date_range=date_range)
run_efast(season, site_position, site_name, cleaning_strategy=strategy, sigma=None, date_range=date_range)
run_efast(season, site_position, site_name, cleaning_strategy=strategy, sigma=sigma_value, date_range=date_range)

View file

@ -1,3 +1,4 @@
"""Index generation: NDVI and GCC from S2/S3/fusion GeoTIFFs."""
import json import json
import numpy as np import numpy as np
import rasterio import rasterio
@ -70,37 +71,37 @@ def _get_ndvi_from_original(input_file, site_position):
with rasterio.open(input_file) as src: with rasterio.open(input_file) as src:
if src.count < 4: if src.count < 4:
return None return None
red = src.read(RED_BAND).astype(np.float32) red = src.read(RED_BAND).astype(np.float32)
nir = src.read(NIR_BAND).astype(np.float32) nir = src.read(NIR_BAND).astype(np.float32)
lon, lat = site_position[1], site_position[0] lon, lat = site_position[1], site_position[0]
x, y = transform_coords("EPSG:4326", src.crs, [lon], [lat]) x, y = transform_coords("EPSG:4326", src.crs, [lon], [lat])
if not ( if not (
src.bounds.left <= x[0] <= src.bounds.right src.bounds.left <= x[0] <= src.bounds.right
and src.bounds.bottom <= y[0] <= src.bounds.top and src.bounds.bottom <= y[0] <= src.bounds.top
): ):
return None return None
row, col = src.index(x[0], y[0]) row, col = src.index(x[0], y[0])
if row < 0 or row >= src.height or col < 0 or col >= src.width: if row < 0 or row >= src.height or col < 0 or col >= src.width:
return None return None
# Extract 3x3 window with boundary handling # Extract 3x3 window with boundary handling
r0, r1 = max(0, row - 1), min(src.height, row + 2) r0, r1 = max(0, row - 1), min(src.height, row + 2)
c0, c1 = max(0, col - 1), min(src.width, col + 2) c0, c1 = max(0, col - 1), min(src.width, col + 2)
red_window = red[r0:r1, c0:c1] red_window = red[r0:r1, c0:c1]
nir_window = nir[r0:r1, c0:c1] nir_window = nir[r0:r1, c0:c1]
# Calculate NDVI for each pixel in window # Calculate NDVI for each pixel in window
mask = (red_window > 0) & (nir_window > 0) & ~np.isnan(red_window) & ~np.isnan(nir_window) mask = (red_window > 0) & (nir_window > 0) & ~np.isnan(red_window) & ~np.isnan(nir_window)
if not np.any(mask): if not np.any(mask):
return None return None
ndvi_window = np.zeros_like(red_window, dtype=np.float32) ndvi_window = np.zeros_like(red_window, dtype=np.float32)
ndvi_window[mask] = (nir_window[mask] - red_window[mask]) / (nir_window[mask] + red_window[mask]) ndvi_window[mask] = (nir_window[mask] - red_window[mask]) / (nir_window[mask] + red_window[mask])
# Return mean of valid NDVI values # Return mean of valid NDVI values
valid_ndvi = ndvi_window[mask] valid_ndvi = ndvi_window[mask]
return float(np.mean(valid_ndvi)) if len(valid_ndvi) > 0 else None return float(np.mean(valid_ndvi)) if len(valid_ndvi) > 0 else None
@ -115,7 +116,7 @@ def _create_timeseries_for_dir(input_dir, output_dir, site_position, source_name
for input_file in sorted(input_dir.glob(pattern)): for input_file in sorted(input_dir.glob(pattern)):
if "DIST_CLOUD" in input_file.name: if "DIST_CLOUD" in input_file.name:
continue continue
filename = input_file.name filename = input_file.name
parts = filename.replace(".geotiff", "").split("_") parts = filename.replace(".geotiff", "").split("_")
date_str = None date_str = None
@ -307,31 +308,31 @@ def _get_gcc_from_original(input_file, site_position):
with rasterio.open(input_file) as src: with rasterio.open(input_file) as src:
if src.count < 3: if src.count < 3:
return None return None
blue = src.read(BLUE_BAND).astype(np.float32) blue = src.read(BLUE_BAND).astype(np.float32)
green = src.read(GREEN_BAND).astype(np.float32) green = src.read(GREEN_BAND).astype(np.float32)
red = src.read(RED_BAND).astype(np.float32) red = src.read(RED_BAND).astype(np.float32)
lon, lat = site_position[1], site_position[0] lon, lat = site_position[1], site_position[0]
x, y = transform_coords("EPSG:4326", src.crs, [lon], [lat]) x, y = transform_coords("EPSG:4326", src.crs, [lon], [lat])
if not ( if not (
src.bounds.left <= x[0] <= src.bounds.right src.bounds.left <= x[0] <= src.bounds.right
and src.bounds.bottom <= y[0] <= src.bounds.top and src.bounds.bottom <= y[0] <= src.bounds.top
): ):
return None return None
row, col = src.index(x[0], y[0]) row, col = src.index(x[0], y[0])
if row < 0 or row >= src.height or col < 0 or col >= src.width: if row < 0 or row >= src.height or col < 0 or col >= src.width:
return None return None
# Extract 3x3 window with boundary handling # Extract 3x3 window with boundary handling
r0, r1 = max(0, row - 1), min(src.height, row + 2) r0, r1 = max(0, row - 1), min(src.height, row + 2)
c0, c1 = max(0, col - 1), min(src.width, col + 2) c0, c1 = max(0, col - 1), min(src.width, col + 2)
blue_window = blue[r0:r1, c0:c1] blue_window = blue[r0:r1, c0:c1]
green_window = green[r0:r1, c0:c1] green_window = green[r0:r1, c0:c1]
red_window = red[r0:r1, c0:c1] red_window = red[r0:r1, c0:c1]
# Calculate GCC for each pixel in window # Calculate GCC for each pixel in window
total = red_window + green_window + blue_window total = red_window + green_window + blue_window
mask = (total > 0) & ~np.isnan(total) & (blue_window >= 0) & (green_window >= 0) & (red_window >= 0) mask = (total > 0) & ~np.isnan(total) & (blue_window >= 0) & (green_window >= 0) & (red_window >= 0)
@ -340,10 +341,10 @@ def _get_gcc_from_original(input_file, site_position):
if negative_pixels > 0: if negative_pixels > 0:
print(f"Warning: {input_file.name} excluded - all pixels have negative band values ({negative_pixels} negative pixels in window)") print(f"Warning: {input_file.name} excluded - all pixels have negative band values ({negative_pixels} negative pixels in window)")
return None return None
gcc_window = np.zeros_like(green_window, dtype=np.float32) gcc_window = np.zeros_like(green_window, dtype=np.float32)
gcc_window[mask] = green_window[mask] / total[mask] gcc_window[mask] = green_window[mask] / total[mask]
# Return mean of valid GCC values # Return mean of valid GCC values
valid_gcc = gcc_window[mask] valid_gcc = gcc_window[mask]
return float(np.mean(valid_gcc)) if len(valid_gcc) > 0 else None return float(np.mean(valid_gcc)) if len(valid_gcc) > 0 else None
@ -358,7 +359,7 @@ def _create_gcc_timeseries_for_dir(input_dir, output_dir, site_position, source_
for input_file in sorted(input_dir.glob(pattern)): for input_file in sorted(input_dir.glob(pattern)):
if "DIST_CLOUD" in input_file.name: if "DIST_CLOUD" in input_file.name:
continue continue
filename = input_file.name filename = input_file.name
parts = filename.replace(".geotiff", "").split("_") parts = filename.replace(".geotiff", "").split("_")
date_str = None date_str = None
@ -451,3 +452,60 @@ def create_gcc_timeseries_post_process(season, site_position, site_name):
input_dir = Path(f"data/{site_name}/{season}/{processed_dir}/fusion/") input_dir = Path(f"data/{site_name}/{season}/{processed_dir}/fusion/")
output_dir = Path(f"data/{site_name}/{season}/{processed_dir}/gcc/fusion/") output_dir = Path(f"data/{site_name}/{season}/{processed_dir}/gcc/fusion/")
_create_gcc_timeseries_for_dir(input_dir, output_dir, site_position, f"POST-PROCESS-FUSION-{strategy}-σ{sigma}") _create_gcc_timeseries_for_dir(input_dir, output_dir, site_position, f"POST-PROCESS-FUSION-{strategy}-σ{sigma}")
def _get_bands_from_original(input_file, site_position):
"""Extract mean B02, B03, B04, B8A from 3x3 window at site. Returns dict or None."""
try:
with rasterio.open(input_file) as src:
if src.count < 4:
return None
lon, lat = site_position[1], site_position[0]
x, y = transform_coords("EPSG:4326", src.crs, [lon], [lat])
if not (
src.bounds.left <= x[0] <= src.bounds.right
and src.bounds.bottom <= y[0] <= src.bounds.top
):
return None
row, col = src.index(x[0], y[0])
r0, r1 = max(0, row - 1), min(src.height, row + 2)
c0, c1 = max(0, col - 1), min(src.width, col + 2)
bands = [src.read(i + 1, window=((r0, r1), (c0, c1))).astype(np.float32) for i in range(4)]
mask = ~np.any([np.isnan(b) for b in bands], axis=0)
mask &= np.all([b > 0 for b in bands], axis=0)
if not np.any(mask):
return None
return {
"b02": float(np.mean(bands[0][mask])),
"b03": float(np.mean(bands[1][mask])),
"b04": float(np.mean(bands[2][mask])),
"b8a": float(np.mean(bands[3][mask])),
}
except Exception:
return None
def _create_s2_bands_timeseries_for_dir(input_dir, output_dir, site_position):
print(f"[S2-BANDS] Creating timeseries.json...")
timeseries = []
for f in sorted(input_dir.glob("*.geotiff")):
date_str = f.stem.split("_")[0]
if len(date_str) != 8 or not date_str.isdigit():
continue
date = datetime.strptime(date_str, "%Y%m%d").isoformat()
bands = _get_bands_from_original(f, site_position)
timeseries.append({"date": date, "filename": f.name, **(bands or {})})
timeseries.sort(key=lambda x: x["date"])
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "timeseries.json").write_text(json.dumps(timeseries, indent=2))
print(f"[S2-BANDS] Saved: {output_dir / 'timeseries.json'} ({len(timeseries)} entries)")
def create_s2_bands_timeseries_post_process(season, site_position, site_name):
for strategy in ["aggressive", "nonaggressive"]:
for sigma in [20, 30]:
processed_dir = f"processed_{strategy}_sigma{sigma}"
input_dir = Path(f"data/{site_name}/{season}/{processed_dir}/s2/")
output_dir = Path(f"data/{site_name}/{season}/{processed_dir}/s2_bands/")
if input_dir.exists():
_create_s2_bands_timeseries_for_dir(input_dir, output_dir, site_position)

View file

@ -1,4 +1,4 @@
"""Calculate metrics comparing fusion-derived GCC with phenocam GCC ground truth.""" """Metrics and statistics: temporal/spatial metrics and PhenoCam stats."""
import json import json
import numpy as np import numpy as np
from pathlib import Path from pathlib import Path
@ -7,7 +7,7 @@ from scipy.stats import pearsonr
import rasterio import rasterio
from rasterio.warp import transform as transform_coords from rasterio.warp import transform as transform_coords
from generate_indexes import BLUE_BAND, GREEN_BAND, RED_BAND from metrics_indices import BLUE_BAND, GREEN_BAND, RED_BAND
def load_timeseries(filepath): def load_timeseries(filepath):
@ -25,7 +25,7 @@ def match_dates(fusion_ts, phenocam_ts):
fusion_vals = [] fusion_vals = []
phenocam_vals = [] phenocam_vals = []
dates = [] dates = []
for date in sorted(common_dates): for date in sorted(common_dates):
fusion_val = fusion_ts[date] fusion_val = fusion_ts[date]
phenocam_val = phenocam_ts[date] phenocam_val = phenocam_ts[date]
@ -33,7 +33,7 @@ def match_dates(fusion_ts, phenocam_ts):
fusion_vals.append(fusion_val) fusion_vals.append(fusion_val)
phenocam_vals.append(phenocam_val) phenocam_vals.append(phenocam_val)
dates.append(date) dates.append(date)
return np.array(fusion_vals), np.array(phenocam_vals), dates return np.array(fusion_vals), np.array(phenocam_vals), dates
@ -95,10 +95,10 @@ def nse(y_true, y_pred):
def calculate_temporal_metrics(fusion_ts, phenocam_ts): def calculate_temporal_metrics(fusion_ts, phenocam_ts):
"""Calculate all 6 temporal metrics.""" """Calculate all 6 temporal metrics."""
fusion_vals, phenocam_vals, dates = match_dates(fusion_ts, phenocam_ts) fusion_vals, phenocam_vals, dates = match_dates(fusion_ts, phenocam_ts)
if len(fusion_vals) < 2: if len(fusion_vals) < 2:
return None return None
metrics = { metrics = {
"pearson_r": pearson_correlation(phenocam_vals, fusion_vals), "pearson_r": pearson_correlation(phenocam_vals, fusion_vals),
"r_squared": r_squared(phenocam_vals, fusion_vals), "r_squared": r_squared(phenocam_vals, fusion_vals),
@ -117,7 +117,7 @@ def calculate_phenocam_stats(phenocam_ts):
values = [v for v in phenocam_ts.values() if v is not None] values = [v for v in phenocam_ts.values() if v is not None]
if len(values) == 0: if len(values) == 0:
return None return None
vals = np.array(values) vals = np.array(values)
return { return {
"mean": float(np.mean(vals)), "mean": float(np.mean(vals)),
@ -134,44 +134,44 @@ def _get_spatial_stats_from_raster(raster_file, site_position):
with rasterio.open(raster_file) as src: with rasterio.open(raster_file) as src:
if src.count < 3: if src.count < 3:
return None return None
blue = src.read(BLUE_BAND).astype(np.float32) blue = src.read(BLUE_BAND).astype(np.float32)
green = src.read(GREEN_BAND).astype(np.float32) green = src.read(GREEN_BAND).astype(np.float32)
red = src.read(RED_BAND).astype(np.float32) red = src.read(RED_BAND).astype(np.float32)
lon, lat = site_position[1], site_position[0] lon, lat = site_position[1], site_position[0]
x, y = transform_coords("EPSG:4326", src.crs, [lon], [lat]) x, y = transform_coords("EPSG:4326", src.crs, [lon], [lat])
if not ( if not (
src.bounds.left <= x[0] <= src.bounds.right src.bounds.left <= x[0] <= src.bounds.right
and src.bounds.bottom <= y[0] <= src.bounds.top and src.bounds.bottom <= y[0] <= src.bounds.top
): ):
return None return None
row, col = src.index(x[0], y[0]) row, col = src.index(x[0], y[0])
if row < 0 or row >= src.height or col < 0 or col >= src.width: if row < 0 or row >= src.height or col < 0 or col >= src.width:
return None return None
# Extract 3x3 window with boundary handling # Extract 3x3 window with boundary handling
r0, r1 = max(0, row - 1), min(src.height, row + 2) r0, r1 = max(0, row - 1), min(src.height, row + 2)
c0, c1 = max(0, col - 1), min(src.width, col + 2) c0, c1 = max(0, col - 1), min(src.width, col + 2)
blue_window = blue[r0:r1, c0:c1] blue_window = blue[r0:r1, c0:c1]
green_window = green[r0:r1, c0:c1] green_window = green[r0:r1, c0:c1]
red_window = red[r0:r1, c0:c1] red_window = red[r0:r1, c0:c1]
# Calculate GCC for each pixel in window # Calculate GCC for each pixel in window
total = red_window + green_window + blue_window total = red_window + green_window + blue_window
mask = (total > 0) & ~np.isnan(total) & (blue_window >= 0) & (green_window >= 0) & (red_window >= 0) mask = (total > 0) & ~np.isnan(total) & (blue_window >= 0) & (green_window >= 0) & (red_window >= 0)
if not np.any(mask): if not np.any(mask):
return None return None
gcc_window = np.zeros_like(green_window, dtype=np.float32) gcc_window = np.zeros_like(green_window, dtype=np.float32)
gcc_window[mask] = green_window[mask] / total[mask] gcc_window[mask] = green_window[mask] / total[mask]
valid_gcc = gcc_window[mask] valid_gcc = gcc_window[mask]
if len(valid_gcc) == 0: if len(valid_gcc) == 0:
return None return None
return { return {
"mean": float(np.mean(valid_gcc)), "mean": float(np.mean(valid_gcc)),
"std": float(np.std(valid_gcc)), "std": float(np.std(valid_gcc)),
@ -187,15 +187,15 @@ def calculate_spatial_metrics(fusion_raster_dir, phenocam_ts, site_position):
fusion_raster_dir = Path(fusion_raster_dir) fusion_raster_dir = Path(fusion_raster_dir)
if not fusion_raster_dir.exists(): if not fusion_raster_dir.exists():
return None return None
spatial_means = [] spatial_means = []
phenocam_vals = [] phenocam_vals = []
# Process each fusion raster file # Process each fusion raster file
for raster_file in sorted(fusion_raster_dir.glob("*.geotiff")): for raster_file in sorted(fusion_raster_dir.glob("*.geotiff")):
if "DIST_CLOUD" in raster_file.name: if "DIST_CLOUD" in raster_file.name:
continue continue
# Extract date from filename # Extract date from filename
parts = raster_file.stem.split("_") parts = raster_file.stem.split("_")
date_str = None date_str = None
@ -203,35 +203,35 @@ def calculate_spatial_metrics(fusion_raster_dir, phenocam_ts, site_position):
if len(part) == 8 and part.isdigit(): if len(part) == 8 and part.isdigit():
date_str = part date_str = part
break break
if not date_str: if not date_str:
continue continue
# Convert to ISO format for matching # Convert to ISO format for matching
try: try:
date = datetime.strptime(date_str, "%Y%m%d").isoformat() date = datetime.strptime(date_str, "%Y%m%d").isoformat()
except ValueError: except ValueError:
continue continue
# Get phenocam value for this date # Get phenocam value for this date
phenocam_val = phenocam_ts.get(date) phenocam_val = phenocam_ts.get(date)
if phenocam_val is None: if phenocam_val is None:
continue continue
# Extract spatial statistics # Extract spatial statistics
stats = _get_spatial_stats_from_raster(raster_file, site_position) stats = _get_spatial_stats_from_raster(raster_file, site_position)
if stats is None: if stats is None:
continue continue
spatial_means.append(stats["mean"]) spatial_means.append(stats["mean"])
phenocam_vals.append(phenocam_val) phenocam_vals.append(phenocam_val)
if len(spatial_means) < 2: if len(spatial_means) < 2:
return None return None
spatial_means = np.array(spatial_means) spatial_means = np.array(spatial_means)
phenocam_vals = np.array(phenocam_vals) phenocam_vals = np.array(phenocam_vals)
return { return {
"pearson_r": pearson_correlation(phenocam_vals, spatial_means), "pearson_r": pearson_correlation(phenocam_vals, spatial_means),
"r_squared": r_squared(phenocam_vals, spatial_means), "r_squared": r_squared(phenocam_vals, spatial_means),
@ -243,24 +243,24 @@ def calculate_scenario_metrics(season, site_name, strategy, sigma, site_position
"""Calculate metrics for one scenario.""" """Calculate metrics for one scenario."""
base = Path(f"data/{site_name}/{season}") base = Path(f"data/{site_name}/{season}")
processed_dir = f"processed_{strategy}_sigma{sigma}" processed_dir = f"processed_{strategy}_sigma{sigma}"
# Load timeseries # Load timeseries
fusion_ts_path = base / processed_dir / "gcc" / "fusion" / "timeseries.json" fusion_ts_path = base / processed_dir / "gcc" / "fusion" / "timeseries.json"
phenocam_ts_path = base / "raw" / "phenocam" / "timeseries.json" phenocam_ts_path = base / "raw" / "phenocam" / "timeseries.json"
fusion_ts = load_timeseries(fusion_ts_path) fusion_ts = load_timeseries(fusion_ts_path)
phenocam_ts = load_timeseries(phenocam_ts_path) phenocam_ts = load_timeseries(phenocam_ts_path)
if not fusion_ts or not phenocam_ts: if not fusion_ts or not phenocam_ts:
return None, None return None, None
# Calculate temporal metrics # Calculate temporal metrics
temporal_metrics = calculate_temporal_metrics(fusion_ts, phenocam_ts) temporal_metrics = calculate_temporal_metrics(fusion_ts, phenocam_ts)
# Calculate spatial metrics # Calculate spatial metrics
fusion_raster_dir = base / processed_dir / "fusion" fusion_raster_dir = base / processed_dir / "fusion"
spatial_metrics = calculate_spatial_metrics(fusion_raster_dir, phenocam_ts, site_position) spatial_metrics = calculate_spatial_metrics(fusion_raster_dir, phenocam_ts, site_position)
return temporal_metrics, spatial_metrics return temporal_metrics, spatial_metrics
@ -268,20 +268,20 @@ def calculate_all_metrics(season, site_name, site_position):
"""Calculate metrics for all 4 scenarios and save to JSON.""" """Calculate metrics for all 4 scenarios and save to JSON."""
results = {"temporal": {}, "spatial": {}} results = {"temporal": {}, "spatial": {}}
base = Path(f"data/{site_name}/{season}") base = Path(f"data/{site_name}/{season}")
# Load phenocam timeseries once (same for all scenarios) # Load phenocam timeseries once (same for all scenarios)
phenocam_ts_path = base / "raw" / "phenocam" / "timeseries.json" phenocam_ts_path = base / "raw" / "phenocam" / "timeseries.json"
phenocam_ts = load_timeseries(phenocam_ts_path) phenocam_ts = load_timeseries(phenocam_ts_path)
if not phenocam_ts: if not phenocam_ts:
print("[METRICS] Warning: No phenocam data found") print("[METRICS] Warning: No phenocam data found")
return results return results
# Calculate phenocam stats # Calculate phenocam stats
phenocam_stats = calculate_phenocam_stats(phenocam_ts) phenocam_stats = calculate_phenocam_stats(phenocam_ts)
if phenocam_stats: if phenocam_stats:
results["phenocam_stats"] = phenocam_stats results["phenocam_stats"] = phenocam_stats
# Calculate S2 baseline metrics once (S2 data is identical across scenarios) # Calculate S2 baseline metrics once (S2 data is identical across scenarios)
s2_ts_path = base / "processed_aggressive_sigma20" / "gcc" / "s2" / "timeseries.json" s2_ts_path = base / "processed_aggressive_sigma20" / "gcc" / "s2" / "timeseries.json"
s2_ts = load_timeseries(s2_ts_path) s2_ts = load_timeseries(s2_ts_path)
@ -289,34 +289,34 @@ def calculate_all_metrics(season, site_name, site_position):
s2_metrics = calculate_temporal_metrics(s2_ts, phenocam_ts) s2_metrics = calculate_temporal_metrics(s2_ts, phenocam_ts)
if s2_metrics: if s2_metrics:
results["baseline"] = {"s2": s2_metrics} results["baseline"] = {"s2": s2_metrics}
# Calculate fusion metrics for each scenario # Calculate fusion metrics for each scenario
for strategy in ["aggressive", "nonaggressive"]: for strategy in ["aggressive", "nonaggressive"]:
for sigma in [20, 30]: for sigma in [20, 30]:
scenario_name = f"{strategy}_sigma{sigma}" scenario_name = f"{strategy}_sigma{sigma}"
print(f"[METRICS] Calculating metrics for {scenario_name}...") print(f"[METRICS] Calculating metrics for {scenario_name}...")
processed_dir = f"processed_{strategy}_sigma{sigma}" processed_dir = f"processed_{strategy}_sigma{sigma}"
# Load fusion timeseries # Load fusion timeseries
fusion_ts_path = base / processed_dir / "gcc" / "fusion" / "timeseries.json" fusion_ts_path = base / processed_dir / "gcc" / "fusion" / "timeseries.json"
fusion_ts = load_timeseries(fusion_ts_path) fusion_ts = load_timeseries(fusion_ts_path)
if not fusion_ts: if not fusion_ts:
print(f"[METRICS] Warning: Missing fusion data for {scenario_name}, skipping") print(f"[METRICS] Warning: Missing fusion data for {scenario_name}, skipping")
continue continue
# Calculate temporal metrics # Calculate temporal metrics
temporal_metrics = calculate_temporal_metrics(fusion_ts, phenocam_ts) temporal_metrics = calculate_temporal_metrics(fusion_ts, phenocam_ts)
if temporal_metrics: if temporal_metrics:
results["temporal"][scenario_name] = temporal_metrics results["temporal"][scenario_name] = temporal_metrics
# Calculate spatial metrics # Calculate spatial metrics
fusion_raster_dir = base / processed_dir / "fusion" fusion_raster_dir = base / processed_dir / "fusion"
spatial_metrics = calculate_spatial_metrics(fusion_raster_dir, phenocam_ts, site_position) spatial_metrics = calculate_spatial_metrics(fusion_raster_dir, phenocam_ts, site_position)
if spatial_metrics: if spatial_metrics:
results["spatial"][scenario_name] = spatial_metrics results["spatial"][scenario_name] = spatial_metrics
# Add summary # Add summary
if results["temporal"]: if results["temporal"]:
best_temporal = max( best_temporal = max(
@ -324,7 +324,7 @@ def calculate_all_metrics(season, site_name, site_position):
key=lambda x: x[1].get("r_squared", -1) if x[1].get("r_squared") is not None else -1 key=lambda x: x[1].get("r_squared", -1) if x[1].get("r_squared") is not None else -1
) )
results["summary"] = {"best_temporal_scenario": best_temporal[0]} results["summary"] = {"best_temporal_scenario": best_temporal[0]}
if results["spatial"]: if results["spatial"]:
best_spatial = max( best_spatial = max(
results["spatial"].items(), results["spatial"].items(),
@ -333,38 +333,38 @@ def calculate_all_metrics(season, site_name, site_position):
if "summary" not in results: if "summary" not in results:
results["summary"] = {} results["summary"] = {}
results["summary"]["best_spatial_scenario"] = best_spatial[0] results["summary"]["best_spatial_scenario"] = best_spatial[0]
# Save results # Save results
output_path = Path(f"data/{site_name}/{season}/metrics.json") output_path = Path(f"data/{site_name}/{season}/metrics.json")
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f: with open(output_path, "w") as f:
json.dump(results, f, indent=2) json.dump(results, f, indent=2)
print(f"[METRICS] Saved results to {output_path}") print(f"[METRICS] Saved results to {output_path}")
return results return results
def main(): def main():
"""Standalone script entry point.""" """Standalone script entry point."""
import sys import sys
if len(sys.argv) < 4: if len(sys.argv) < 4:
print("Usage: calculate_metrics.py <season> <site_name> <lat> <lon>") print("Usage: metrics_stats.py <season> <site_name> <lat> <lon>")
print("Example: calculate_metrics.py 2024 innsbruck 47.116171 11.320308") print("Example: metrics_stats.py 2024 innsbruck 47.116171 11.320308")
sys.exit(1) sys.exit(1)
season = int(sys.argv[1]) season = int(sys.argv[1])
site_name = sys.argv[2] site_name = sys.argv[2]
site_position = (float(sys.argv[3]), float(sys.argv[4])) site_position = (float(sys.argv[3]), float(sys.argv[4]))
results = calculate_all_metrics(season, site_name, site_position) results = calculate_all_metrics(season, site_name, site_position)
# Save results # Save results
output_path = Path(f"data/{site_name}/{season}/metrics.json") output_path = Path(f"data/{site_name}/{season}/metrics.json")
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f: with open(output_path, "w") as f:
json.dump(results, f, indent=2) json.dump(results, f, indent=2)
print(f"[METRICS] Saved results to {output_path}") print(f"[METRICS] Saved results to {output_path}")

View file

@ -1,3 +1,4 @@
"""Post-processing: crop fusion/S2/S3 to valid pixels."""
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
import rasterio import rasterio
@ -12,16 +13,16 @@ def process_cropped(season, site_position, site_name, cleaning_strategy="aggress
prepared = base / f"prepared_{cleaning_strategy}" prepared = base / f"prepared_{cleaning_strategy}"
processed_dir = f"processed_{cleaning_strategy}_sigma{sigma}" if sigma else f"processed_{cleaning_strategy}_sigma20" processed_dir = f"processed_{cleaning_strategy}_sigma{sigma}" if sigma else f"processed_{cleaning_strategy}_sigma20"
processed = base / processed_dir processed = base / processed_dir
s2_prep = prepared / "s2" s2_prep = prepared / "s2"
s3_prep = prepared / "s3" s3_prep = prepared / "s3"
fusion_prep = prepared / (f"fusion_sigma{sigma}" if sigma else "fusion") fusion_prep = prepared / (f"fusion_sigma{sigma}" if sigma else "fusion")
for output_dir in [processed / "s2", processed / "s3", processed / "fusion"]: for output_dir in [processed / "s2", processed / "s3", processed / "fusion"]:
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
print(f"[PROCESS] Processing files: {site_name}, {season}, {cleaning_strategy}, sigma={sigma or 20}") print(f"[PROCESS] Processing files: {site_name}, {season}, {cleaning_strategy}, sigma={sigma or 20}")
# Crop fusion to valid data and get dimensions # Crop fusion to valid data and get dimensions
fusion_dims = {} fusion_dims = {}
for fusion_file in fusion_prep.glob("REFL_*.tif"): for fusion_file in fusion_prep.glob("REFL_*.tif"):
@ -49,7 +50,7 @@ def process_cropped(season, site_position, site_name, cleaning_strategy="aggress
dst.write(data_crop) dst.write(data_crop)
fusion_dims[date_str] = (c0, r0, w, h, transform, src.transform, src.crs, src.profile) fusion_dims[date_str] = (c0, r0, w, h, transform, src.transform, src.crs, src.profile)
print(f"[PROCESS] Cropped fusion: {output_file}") print(f"[PROCESS] Cropped fusion: {output_file}")
# Crop S2 and S3 to fusion size # Crop S2 and S3 to fusion size
for date_str, (c0, r0, w, h, transform, fusion_transform, crs, fusion_profile) in fusion_dims.items(): for date_str, (c0, r0, w, h, transform, fusion_transform, crs, fusion_profile) in fusion_dims.items():
window = windows.Window(c0, r0, w, h) window = windows.Window(c0, r0, w, h)
@ -91,7 +92,7 @@ def process_cropped(season, site_position, site_name, cleaning_strategy="aggress
with rasterio.open(output_file, "w", **p2) as dst: with rasterio.open(output_file, "w", **p2) as dst:
dst.write(data) dst.write(data)
print(f"[PROCESS] Cropped: {output_file}") print(f"[PROCESS] Cropped: {output_file}")
print("[PROCESS] Completed") print("[PROCESS] Completed")
@ -100,3 +101,8 @@ def process_all_scenarios(season, site_position, site_name):
for strategy in ["aggressive", "nonaggressive"]: for strategy in ["aggressive", "nonaggressive"]:
for sigma in [None, 30]: for sigma in [None, 30]:
process_cropped(season, site_position, site_name, cleaning_strategy=strategy, sigma=sigma) process_cropped(season, site_position, site_name, cleaning_strategy=strategy, sigma=sigma)
# Aliases
postprocess = process_cropped
postprocess_all_scenarios = process_all_scenarios

View file

@ -1,7 +1,7 @@
"""Data preparation: S2/S3 preprocessing for fusion."""
import json import json
import shutil import shutil
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta
from collections import defaultdict from collections import defaultdict
import numpy as np import numpy as np
import rasterio import rasterio
@ -9,24 +9,20 @@ from rasterio.warp import Resampling
from rasterio.vrt import WarpedVRT from rasterio.vrt import WarpedVRT
from rasterio import shutil as rio_shutil from rasterio import shutil as rio_shutil
RESOLUTION_RATIO = 21
def _import_efast():
"""Lazy import of efast to avoid import errors when not using efast functions.""" def _import_distance_to_clouds():
"""Lazy import of efast.distance_to_clouds."""
try: try:
import efast
from efast.s2_processing import distance_to_clouds from efast.s2_processing import distance_to_clouds
from efast.s3_processing import reproject_and_crop_s3 return distance_to_clouds
return efast, distance_to_clouds, reproject_and_crop_s3
except ImportError: except ImportError:
raise ImportError( raise ImportError(
"efast package not found. Install with: pip install git+https://github.com/DHI-GRAS/efast.git" "efast package not found. Install with: pip install git+https://github.com/DHI-GRAS/efast.git"
) )
RESOLUTION_RATIO = 21
def _load_clouds(clouds_file): def _load_clouds(clouds_file):
clouds = {"s2": set(), "s3": set()} clouds = {"s2": set(), "s3": set()}
if clouds_file.exists(): if clouds_file.exists():
@ -100,12 +96,7 @@ def prepare_s2(season, site_position, site_name, cleaning_strategy="aggressive",
temp_normalized = s2_output_dir / f"temp_{s2_file.name}" temp_normalized = s2_output_dir / f"temp_{s2_file.name}"
with rasterio.open(s2_file) as src: with rasterio.open(s2_file) as src:
pb = src.tags().get("PROCESSING_BASELINE", "") data = src.read().astype("float32") / 10000.0
data = src.read().astype("float32")
mask_nodata = data == 0
data = (data - 1000) / 10000.0 if pb >= "04.00" else data / 10000.0
data = np.maximum(data, 0)
data[mask_nodata] = 0
profile = src.profile.copy() profile = src.profile.copy()
profile.update({"dtype": "float32", "nodata": 0}) profile.update({"dtype": "float32", "nodata": 0})
with rasterio.open(temp_normalized, "w", **profile) as dst: with rasterio.open(temp_normalized, "w", **profile) as dst:
@ -116,7 +107,7 @@ def prepare_s2(season, site_position, site_name, cleaning_strategy="aggressive",
) )
temp_normalized.unlink() temp_normalized.unlink()
_, distance_to_clouds, _ = _import_efast() distance_to_clouds = _import_distance_to_clouds()
distance_to_clouds(s2_output_dir, ratio=RESOLUTION_RATIO) distance_to_clouds(s2_output_dir, ratio=RESOLUTION_RATIO)
@ -200,59 +191,3 @@ def prepare_s3(season, site_position, site_name, cleaning_strategy="aggressive",
rio_shutil.copy(vrt, outfile, **profile) rio_shutil.copy(vrt, outfile, **profile)
shutil.rmtree(temp_composite_dir) shutil.rmtree(temp_composite_dir)
def run_efast(season, site_position, site_name, cleaning_strategy="aggressive", sigma=None, date_range=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")
fusion_output_dir.mkdir(parents=True, exist_ok=True)
print(f"[EFAST] Starting fusion: {site_name} ({lat:.6f}, {lon:.6f}), {season}")
efast, _, _ = _import_efast()
start_str, end_str = datetime_range.split("/")
start_date = datetime.strptime(start_str, "%Y-%m-%d")
end_date = datetime.strptime(end_str, "%Y-%m-%d")
current_date = start_date
while current_date <= end_date:
date_str = current_date.strftime("%Y%m%d")
output_file = fusion_output_dir / f"REFL_{date_str}.tif"
try:
kwargs = {
"product": "REFL",
"max_days": 30,
"date_position": 2,
"minimum_acquisition_importance": 0.0,
"ratio": RESOLUTION_RATIO,
}
if sigma is not None:
kwargs["sigma"] = sigma
efast.fusion(current_date, s3_output_dir, s2_output_dir, fusion_output_dir, **kwargs)
print(
f"[EFAST] Saved: {output_file}"
if output_file.exists()
else f"[EFAST] No output for {date_str} (insufficient nearby data)"
)
except Exception as e:
print(f"[EFAST] Error processing {date_str}: {e}")
current_date += timedelta(days=1)
print("[EFAST] Completed")
def run_all_efast_scenarios(season, site_position, site_name, sigma_value=30, date_range=None):
from clouds import detect_clouds
for strategy in ["aggressive", "nonaggressive"]:
detect_clouds(season, site_name, cleaning_strategy=strategy)
prepare_s2(season, site_position, site_name, cleaning_strategy=strategy, date_range=date_range)
prepare_s3(season, site_position, site_name, cleaning_strategy=strategy, date_range=date_range)
run_efast(season, site_position, site_name, cleaning_strategy=strategy, sigma=None, date_range=date_range)
run_efast(season, site_position, site_name, cleaning_strategy=strategy, sigma=sigma_value, date_range=date_range)

View file

@ -1,3 +1,4 @@
"""Pre-selection: NDVI-based cloud/flaw filtering for S2 and S3 data."""
import json import json
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
@ -8,6 +9,7 @@ THRESHOLDS = {"aggressive": {"threshold": 0.3, "delta": 0.15}, "nonaggressive":
def detect_clouds(season, site_name, cleaning_strategy="aggressive"): def detect_clouds(season, site_name, cleaning_strategy="aggressive"):
"""Filter cloud-covered/flawed S2 and S3 files using NDVI thresholds."""
output_file = Path(f"data/{site_name}/{season}/clouds_{cleaning_strategy}.json") output_file = Path(f"data/{site_name}/{season}/clouds_{cleaning_strategy}.json")
clouds = {"s2": [], "s3": []} clouds = {"s2": [], "s3": []}
thresholds = THRESHOLDS[cleaning_strategy] thresholds = THRESHOLDS[cleaning_strategy]
@ -61,3 +63,7 @@ def detect_clouds(season, site_name, cleaning_strategy="aggressive"):
json.dump(clouds, f, indent=2) json.dump(clouds, f, indent=2)
print(f"[CLOUDS] Saved: {output_file}") print(f"[CLOUDS] Saved: {output_file}")
# Alias for backward compatibility
preselect = detect_clouds

38
run.py
View file

@ -1,31 +1,28 @@
from call_efast import run_all_efast_scenarios from fusion import run_all_efast_scenarios
from post_process import process_all_scenarios from postprocessing import process_all_scenarios
from generate_indexes import ( from metrics_indices import (
generate_ndvi_raw,
create_ndvi_timeseries_raw, create_ndvi_timeseries_raw,
generate_ndvi_post_process,
create_ndvi_timeseries_post_process, create_ndvi_timeseries_post_process,
generate_gcc_post_process,
create_gcc_timeseries_post_process, create_gcc_timeseries_post_process,
create_s2_bands_timeseries_post_process,
) )
from download_s2 import download_s2 from acquisition_s2 import download_s2
from download_s3 import download_s3 from acquisition_s3 import download_s3
from download_phenocam import download_phenocam, download_phenocam_greenness from acquisition_phenocam import download_phenocam, download_phenocam_greenness
from clouds import detect_clouds from metrics_stats import calculate_all_metrics
from calculate_metrics import calculate_all_metrics
def run_pipeline(season, site_position, site_name): def run_pipeline(season, site_position, site_name):
"""Run pipeline (downloads + EFAST fusion + post-process + metrics).""" """Run pipeline (downloads + EFAST fusion + post-process + metrics)."""
try: try:
# Download steps (needed for new site/season) # Download steps (needed for new site/season)
download_s2(season, site_position, site_name) #download_s2(season, site_position, site_name)
download_s3(season, site_position, site_name) #download_s3(season, site_position, site_name)
download_phenocam(season, site_position, site_name) #download_phenocam(season, site_position, site_name)
download_phenocam_greenness(season, site_position, site_name) #download_phenocam_greenness(season, site_position, site_name)
print(f"Generating NDVI for raw data: {site_name}, {season}") #print(f"Generating NDVI for raw data: {site_name}, {season}")
create_ndvi_timeseries_raw(season, site_position, site_name) #create_ndvi_timeseries_raw(season, site_position, site_name)
print(f"Running EFAST fusion for all scenarios: {site_name}, {season}") print(f"Running EFAST fusion for all scenarios: {site_name}, {season}")
run_all_efast_scenarios(season, site_position, site_name) run_all_efast_scenarios(season, site_position, site_name)
@ -39,6 +36,9 @@ def run_pipeline(season, site_position, site_name):
print(f"Generating GCC for final outputs: {site_name}, {season}") print(f"Generating GCC for final outputs: {site_name}, {season}")
create_gcc_timeseries_post_process(season, site_position, site_name) create_gcc_timeseries_post_process(season, site_position, site_name)
print(f"Generating S2 band timeseries: {site_name}, {season}")
create_s2_bands_timeseries_post_process(season, site_position, site_name)
print(f"Calculating metrics: {site_name}, {season}") print(f"Calculating metrics: {site_name}, {season}")
calculate_all_metrics(season, site_name, site_position) calculate_all_metrics(season, site_name, site_position)
@ -48,6 +48,8 @@ def run_pipeline(season, site_position, site_name):
if __name__ == "__main__": if __name__ == "__main__":
run_pipeline(2024, (47.116171, 11.320308), "innsbruck")
# forthgr - FORTH Heraklion Greece, Agriculture, 2024 # forthgr - FORTH Heraklion Greece, Agriculture, 2024
# sites.geojson: lon=25.0743, lat=35.3045 # sites.geojson: lon=25.0743, lat=35.3045
run_pipeline(2024, (35.3045, 25.0743), "forthgr") #run_pipeline(2024, (35.3045, 25.0743), "forthgr")

View file

@ -11,6 +11,9 @@
.slider-container { position: sticky; top: 0; background: white; padding: 20px; z-index: 1000; border-bottom: 1px solid #ccc; } .slider-container { position: sticky; top: 0; background: white; padding: 20px; z-index: 1000; border-bottom: 1px solid #ccc; }
.scenario-selector { margin-bottom: 10px; } .scenario-selector { margin-bottom: 10px; }
.scenario-selector select { padding: 5px 10px; font-size: 14px; } .scenario-selector select { padding: 5px 10px; font-size: 14px; }
.site-selector { margin-bottom: 10px; }
.site-selector select { padding: 5px 10px; font-size: 14px; }
.site-selector label { margin-right: 5px; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; } .container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.header { display: flex; gap: 20px; margin-bottom: 20px; border-bottom: 1px solid #ccc; padding-top: 10px;padding-bottom: 20px;} .header { display: flex; gap: 20px; margin-bottom: 20px; border-bottom: 1px solid #ccc; padding-top: 10px;padding-bottom: 20px;}
.header-col { flex: 1; } .header-col { flex: 1; }
@ -60,6 +63,12 @@
<div id="sitemap" class="sitemap"></div> <div id="sitemap" class="sitemap"></div>
</div> </div>
</div> </div>
<div class="site-selector">
<label for="siteSelect">Site: </label>
<select id="siteSelect"></select>
<label for="seasonSelect">Season: </label>
<select id="seasonSelect"></select>
</div>
<div class="scenario-selector"> <div class="scenario-selector">
<label for="scenarioSelect">Scenario: </label> <label for="scenarioSelect">Scenario: </label>
<select id="scenarioSelect"> <select id="scenarioSelect">
@ -118,15 +127,16 @@
proj4.defs("EPSG:32632", "+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs"); proj4.defs("EPSG:32632", "+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs");
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs"); proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
const start = new Date(2024, 0, 1); let start = new Date(2024, 0, 1);
const slider = document.getElementById("dateSlider"); const slider = document.getElementById("dateSlider");
const dateDisplay = document.getElementById("dateDisplay"); const dateDisplay = document.getElementById("dateDisplay");
const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
const osmOpts = { attribution: "OpenStreetMap", opacity: 0.4 }; const osmOpts = { attribution: "OpenStreetMap", opacity: 0.4 };
const mapOpts = { zoomControl: false }; const mapOpts = { zoomControl: false };
const sitePosition = [47.116171, 11.320308]; let sitePosition = [47.116171, 11.320308];
const siteName = "innsbruck"; let siteName = "innsbruck";
const season = "2024"; let season = "2024";
let sitesData = null;
const urlParams = new URLSearchParams(location.search); const urlParams = new URLSearchParams(location.search);
const strategy = urlParams.get("strategy") || "aggressive"; const strategy = urlParams.get("strategy") || "aggressive";
@ -139,7 +149,7 @@
let allScenariosGCC = {}; let allScenariosGCC = {};
let metricsData = null; let metricsData = null;
const siteMap = L.map("sitemap", { zoomControl: false }).setView(sitePosition, 4).addLayer(L.tileLayer(osmUrl, { attribution: "OpenStreetMap", opacity: 1 })); const siteMap = L.map("sitemap", { zoomControl: false }).setView(sitePosition, 4).addLayer(L.tileLayer(osmUrl, { attribution: "OpenStreetMap", opacity: 1 }));
L.marker(sitePosition, { icon: L.divIcon({ className: "site-marker", html: "<div style='width:8px;height:8px;background:red;border:2px solid white;border-radius:50%;box-shadow:0 0 2px rgba(0,0,0,0.5);'></div>", iconSize: [8, 8] }) }).addTo(siteMap); const siteMarker = L.marker(sitePosition, { icon: L.divIcon({ className: "site-marker", html: "<div style='width:8px;height:8px;background:red;border:2px solid white;border-radius:50%;box-shadow:0 0 2px rgba(0,0,0,0.5);'></div>", iconSize: [8, 8] }) }).addTo(siteMap);
const maps = { const maps = {
s2: L.map("s2map", mapOpts).setView(sitePosition, 12).addLayer(L.tileLayer(osmUrl, osmOpts)), s2: L.map("s2map", mapOpts).setView(sitePosition, 12).addLayer(L.tileLayer(osmUrl, osmOpts)),
fusion: L.map("fusionmap", mapOpts).setView(sitePosition, 12).addLayer(L.tileLayer(osmUrl, osmOpts)), fusion: L.map("fusionmap", mapOpts).setView(sitePosition, 12).addLayer(L.tileLayer(osmUrl, osmOpts)),
@ -173,6 +183,7 @@
}); });
async function loadTimeseries() { async function loadTimeseries() {
metricsData = null;
const fusionPath = getFusionPath(); const fusionPath = getFusionPath();
const [s2, fusion, s3, s2gcc, fusiongcc, s3gcc, phenocam] = await Promise.all([ const [s2, fusion, s3, s2gcc, fusiongcc, s3gcc, phenocam] = await Promise.all([
fetch(`../data/${siteName}/${season}/processed_${strategy}_sigma${sigma}/ndvi/s2/timeseries.json`).then(r => r.json()).catch(() => []), fetch(`../data/${siteName}/${season}/processed_${strategy}_sigma${sigma}/ndvi/s2/timeseries.json`).then(r => r.json()).catch(() => []),
@ -526,8 +537,13 @@
function drawMetricsTable() { function drawMetricsTable() {
const container = document.getElementById("metricsTable"); const container = document.getElementById("metricsTable");
const hasAnyData = timeseries.s2.length || timeseries.fusion.length || timeseries.s3.length || phenocamGreennessTimeseries.length;
if (!hasAnyData) {
container.innerHTML = "<p style='color:#666; font-size:12px;'>No data for this site/season.</p>";
return;
}
if (!metricsData || !metricsData.temporal) { if (!metricsData || !metricsData.temporal) {
container.innerHTML = "<p style='color:#666; font-size:12px;'>Metrics not available. Run calculate_metrics.py to generate.</p>"; container.innerHTML = "<p style='color:#666; font-size:12px;'>Metrics not available. Run the pipeline (run.py) or metrics_stats.py to generate.</p>";
return; return;
} }
@ -789,6 +805,8 @@
const date = dateFromDays(parseInt(slider.value)); const date = dateFromDays(parseInt(slider.value));
dateDisplay.textContent = date; dateDisplay.textContent = date;
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("site", siteName);
params.set("season", season);
params.set("date", date); params.set("date", date);
params.set("strategy", strategy); params.set("strategy", strategy);
if (sigma !== "20") params.set("sigma", sigma); if (sigma !== "20") params.set("sigma", sigma);
@ -822,10 +840,131 @@
window.location.search = params.toString(); window.location.search = params.toString();
}); });
const urlDate = urlParams.get("date"); const siteSelect = document.getElementById("siteSelect");
if (urlDate) slider.value = daysFromDate(urlDate); const seasonSelect = document.getElementById("seasonSelect");
function getSiteBySitename(sitename) {
return sitesData?.features?.find(f => f.properties?.sitename === sitename);
}
let availableSiteSeasons = {}; // { sitename: [season, ...] }
function populateSeasonOptions(sitename) {
seasonSelect.innerHTML = "";
const seasons = availableSiteSeasons[sitename] || [];
for (const s of seasons) {
const opt = document.createElement("option");
opt.value = s;
opt.textContent = s;
seasonSelect.appendChild(opt);
}
}
async function probeDataExists(sitename, season) {
try {
const res = await fetch(`../data/${sitename}/${season}/metrics.json`, { method: "HEAD" });
return res.ok;
} catch { return false; }
}
async function setSiteSeason(newSiteName, newSeason) {
const site = getSiteBySitename(newSiteName);
let pos;
let description;
if (site) {
const [lon, lat] = site.geometry.coordinates;
pos = [lat, lon];
description = site.properties.description || newSiteName;
} else {
pos = [47.116171, 11.320308];
description = newSiteName;
}
siteName = newSiteName;
season = newSeason;
sitePosition = pos;
start = new Date(parseInt(season), 0, 1);
const yearEnd = new Date(parseInt(season), 11, 31);
slider.max = Math.ceil((yearEnd - start) / 86400000);
document.getElementById("siteName").textContent = description;
document.getElementById("season").textContent = season;
siteMap.setView(sitePosition, 4);
siteMarker.setLatLng(sitePosition);
for (const source of ["s2", "fusion", "s3"]) {
maps[source].setView(sitePosition, 12);
markers[source].setLatLng(sitePosition);
}
const params = new URLSearchParams(location.search);
params.set("site", siteName);
params.set("season", season);
history.replaceState({}, "", `?${params}`);
await loadTimeseries();
const urlDate = params.get("date");
if (urlDate) slider.value = daysFromDate(urlDate);
await updateImages();
}
async function init() {
try {
const res = await fetch("../data/sites.geojson");
if (!res.ok) throw new Error("Could not load sites");
sitesData = await res.json();
} catch (e) {
console.error("Failed to load sites.geojson:", e);
sitesData = { features: [] };
}
const features = sitesData.features || [];
availableSiteSeasons = {};
for (const f of features) {
const sn = f.properties?.sitename;
if (!sn) continue;
const seasonsFromGeo = f.properties?.seasons ? Object.keys(f.properties.seasons).sort() : [];
const withData = [];
for (const s of seasonsFromGeo) {
if (await probeDataExists(sn, s)) withData.push(s);
}
if (withData.length) availableSiteSeasons[sn] = withData;
}
const availableSites = Object.keys(availableSiteSeasons);
siteSelect.innerHTML = "";
if (availableSites.length === 0) {
const opt = document.createElement("option");
opt.value = "innsbruck";
opt.textContent = "innsbruck";
siteSelect.appendChild(opt);
availableSiteSeasons.innsbruck = ["2024"];
} else {
for (const sn of availableSites.sort()) {
const opt = document.createElement("option");
opt.value = sn;
opt.textContent = sn;
siteSelect.appendChild(opt);
}
}
const urlSite = urlParams.get("site");
const urlSeason = urlParams.get("season");
const initialSite = urlSite && availableSites.includes(urlSite) ? urlSite : availableSites[0] || "innsbruck";
siteName = initialSite;
siteSelect.value = initialSite;
populateSeasonOptions(initialSite);
const seasons = availableSiteSeasons[initialSite] || [];
const initialSeason = urlSeason && seasons.includes(urlSeason) ? urlSeason : (seasons[0] || "2024");
season = initialSeason;
seasonSelect.value = initialSeason;
siteSelect.addEventListener("change", function() {
const sn = this.value;
populateSeasonOptions(sn);
const seas = availableSiteSeasons[sn] || [];
seasonSelect.value = seas[0] || season;
setSiteSeason(sn, seasonSelect.value);
});
seasonSelect.addEventListener("change", function() {
setSiteSeason(siteSelect.value, this.value);
});
await setSiteSeason(initialSite, initialSeason);
}
slider.addEventListener("input", updateImages); slider.addEventListener("input", updateImages);
loadTimeseries().then(updateImages); init();
</script> </script>
</body> </body>
</html> </html>

492
webapp/s2-timeseries.html Normal file
View file

@ -0,0 +1,492 @@
<!DOCTYPE html>
<html>
<head>
<title>S2 Band Reflectance Timeseries</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/geotiff@2.0.7/dist-browser/geotiff.js"></script>
<script src="https://cdn.jsdelivr.net/npm/proj4@2.9.0/dist/proj4.js"></script>
<style>
body { margin: 0; font-family: sans-serif; }
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
.selectors { margin-bottom: 20px; }
.selectors select { padding: 5px 10px; font-size: 14px; margin-right: 15px; }
h1 { margin: 0 0 5px 0; font-size: 22px; }
h2 { margin: 0 0 15px 0; font-size: 16px; color: #666; }
.plot { width: 100%; height: 100px; border: 1px solid #ccc; margin-bottom: 15px; }
.plot-label { font-size: 12px; margin-bottom: 3px; color: #666; }
#dateSlider { width: 100%; margin: 15px 0; }
#dateDisplay { text-align: center; font-size: 14px; color: #666; }
.map-label { font-size: 12px; margin-bottom: 3px; color: #666; }
.map-date { font-size: 11px; margin-top: 3px; color: #999; }
#s2map { height: 400px; border: 1px solid #ccc; margin-top: 10px; }
.leaflet-image-layer { image-rendering: pixelated; }
.leaflet-control-attribution { display: none; }
</style>
</head>
<body>
<div class="container">
<h1 id="siteName">Innsbruck</h1>
<h2 id="season">2024</h2>
<div class="selectors">
<label>Site:</label>
<select id="siteSelect"></select>
<label>Season:</label>
<select id="seasonSelect"></select>
<label>Scenario:</label>
<select id="scenarioSelect">
<option value="aggressive_20">Aggressive σ20</option>
<option value="aggressive_30">Aggressive σ30</option>
<option value="nonaggressive_20">Non-aggressive σ20</option>
<option value="nonaggressive_30">Non-aggressive σ30</option>
</select>
</div>
<input type="range" id="dateSlider" min="0" max="365" value="0">
<div id="dateDisplay">2024-01-01</div>
<div class="map-label">S2 RGB (closest available)</div>
<div id="s2rgbdate" class="map-date"></div>
<div id="s2map"></div>
<div id="bandPlots"></div>
</div>
<script>
proj4.defs("EPSG:32632", "+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs");
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
const BANDS = [
{ key: "b02", label: "B02 (Blue)", color: "#0066ff" },
{ key: "b03", label: "B03 (Green)", color: "#00aa00" },
{ key: "b04", label: "B04 (Red)", color: "#cc0000" },
{ key: "b8a", label: "B8A (NIR)", color: "#9900cc" }
];
let siteName = "innsbruck", season = "2024";
let sitePosition = [47.116171, 11.320308];
let start = new Date(2024, 0, 1);
let timeseries = [];
let gccTimeseries = [];
let ndviTimeseries = [];
let availableSiteSeasons = {};
let s2Map = null, s2Overlay = null, s2Marker = null;
const urlParams = new URLSearchParams(location.search);
const [strategy, sigma] = (urlParams.get("scenario") || "aggressive_20").split("_");
function getBasePath() {
return `processed_${strategy}_sigma${sigma || "20"}`;
}
function fmtDate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const dateFromDays = (days) => fmtDate(new Date(start.getTime() + days * 86400000));
const daysFromDate = (dateStr) => {
const [y, m, d] = dateStr.split("-").map(Number);
return Math.floor((new Date(y, m - 1, d) - start) / 86400000);
};
function drawBandPlot(canvasId, bandKey, bandLabel, color) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 100;
const w = canvas.width, h = canvas.height, pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
const data = timeseries.filter(t => t[bandKey] != null);
if (!data.length) return;
const dates = data.map(t => new Date(t.date));
const values = data.map(t => t[bandKey]);
const minDate = new Date(Math.min(...dates)), maxDate = new Date(Math.max(...dates));
const dateRange = maxDate - minDate || 1;
const minVal = Math.min(...values), maxVal = Math.max(...values);
const valRange = maxVal - minVal || 1;
const x = (d) => pad + ((new Date(d) - minDate) / dateRange) * plotW;
const y = (v) => pad + plotH - ((v - minVal) / valRange) * plotH;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = "#ccc";
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, pad + plotH);
ctx.lineTo(pad + plotW, pad + plotH);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.font = "9px sans-serif";
ctx.fillText(minVal.toFixed(4), 2, pad + plotH + 10);
ctx.fillText(maxVal.toFixed(4), 2, pad + 3);
ctx.strokeStyle = color;
ctx.beginPath();
data.forEach((t, i) => {
const px = x(t.date), py = y(t[bandKey]);
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
ctx.fillStyle = "#888";
const axisY = pad + plotH;
for (const t of data) ctx.fillRect(x(t.date) - 1, axisY - 1, 2, 2);
const currentDate = dateFromDays(parseInt(document.getElementById("dateSlider").value));
const xPos = x(currentDate);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xPos, pad);
ctx.lineTo(xPos, pad + plotH);
ctx.stroke();
const closest = data.reduce((c, t) =>
Math.abs(new Date(t.date) - new Date(currentDate)) < Math.abs(new Date(c.date) - new Date(currentDate)) ? t : c
);
if (closest) {
ctx.fillStyle = "#f00";
ctx.font = "bold 10px sans-serif";
ctx.fillText(closest[bandKey].toFixed(4), xPos + 5, y(closest[bandKey]) - 5);
}
}
function drawNdviPlot() {
const canvas = document.getElementById("plot_ndvi");
if (!canvas) return;
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 100;
const w = canvas.width, h = canvas.height, pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
const data = ndviTimeseries.filter(t => t.ndvi != null);
if (!data.length) return;
const dates = data.map(t => new Date(t.date));
const values = data.map(t => t.ndvi);
const minDate = new Date(Math.min(...dates)), maxDate = new Date(Math.max(...dates));
const dateRange = maxDate - minDate || 1;
const minVal = Math.min(...values), maxVal = Math.max(...values);
const valRange = maxVal - minVal || 1;
const x = (d) => pad + ((new Date(d) - minDate) / dateRange) * plotW;
const y = (v) => pad + plotH - ((v - minVal) / valRange) * plotH;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = "#ccc";
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, pad + plotH);
ctx.lineTo(pad + plotW, pad + plotH);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.font = "9px sans-serif";
ctx.fillText(minVal.toFixed(3), 2, pad + plotH + 10);
ctx.fillText(maxVal.toFixed(3), 2, pad + 3);
ctx.strokeStyle = "#2d7a3e";
ctx.beginPath();
data.forEach((t, i) => {
const px = x(t.date), py = y(t.ndvi);
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
ctx.fillStyle = "#888";
const axisY = pad + plotH;
for (const t of data) ctx.fillRect(x(t.date) - 1, axisY - 1, 2, 2);
const currentDate = dateFromDays(parseInt(document.getElementById("dateSlider").value));
const xPos = x(currentDate);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xPos, pad);
ctx.lineTo(xPos, pad + plotH);
ctx.stroke();
const closest = data.reduce((c, t) =>
Math.abs(new Date(t.date) - new Date(currentDate)) < Math.abs(new Date(c.date) - new Date(currentDate)) ? t : c
);
if (closest) {
ctx.fillStyle = "#f00";
ctx.font = "bold 10px sans-serif";
ctx.fillText(closest.ndvi.toFixed(3), xPos + 5, y(closest.ndvi) - 5);
}
}
function drawGccPlot() {
const canvas = document.getElementById("plot_gcc");
if (!canvas) return;
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth;
canvas.height = 100;
const w = canvas.width, h = canvas.height, pad = 30;
const plotW = w - pad * 2, plotH = h - pad * 2;
const data = gccTimeseries.filter(t => t.greenness_index != null);
if (!data.length) return;
const dates = data.map(t => new Date(t.date));
const values = data.map(t => t.greenness_index);
const minDate = new Date(Math.min(...dates)), maxDate = new Date(Math.max(...dates));
const dateRange = maxDate - minDate || 1;
const minVal = Math.min(...values), maxVal = Math.max(...values);
const valRange = maxVal - minVal || 1;
const x = (d) => pad + ((new Date(d) - minDate) / dateRange) * plotW;
const y = (v) => pad + plotH - ((v - minVal) / valRange) * plotH;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = "#ccc";
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, pad + plotH);
ctx.lineTo(pad + plotW, pad + plotH);
ctx.stroke();
ctx.fillStyle = "#000";
ctx.font = "9px sans-serif";
ctx.fillText(minVal.toFixed(3), 2, pad + plotH + 10);
ctx.fillText(maxVal.toFixed(3), 2, pad + 3);
ctx.strokeStyle = "#00aa00";
ctx.beginPath();
data.forEach((t, i) => {
const px = x(t.date), py = y(t.greenness_index);
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
});
ctx.stroke();
ctx.fillStyle = "#888";
const axisY = pad + plotH;
for (const t of data) ctx.fillRect(x(t.date) - 1, axisY - 1, 2, 2);
const currentDate = dateFromDays(parseInt(document.getElementById("dateSlider").value));
const xPos = x(currentDate);
ctx.strokeStyle = "#f00";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xPos, pad);
ctx.lineTo(xPos, pad + plotH);
ctx.stroke();
const closest = data.reduce((c, t) =>
Math.abs(new Date(t.date) - new Date(currentDate)) < Math.abs(new Date(c.date) - new Date(currentDate)) ? t : c
);
if (closest) {
ctx.fillStyle = "#f00";
ctx.font = "bold 10px sans-serif";
ctx.fillText(closest.greenness_index.toFixed(3), xPos + 5, y(closest.greenness_index) - 5);
}
}
function drawAllPlots() {
drawNdviPlot();
drawGccPlot();
BANDS.forEach(b => drawBandPlot(`plot_${b.key}`, b.key, b.label, b.color));
}
async function loadTimeseries() {
const base = `../data/${siteName}/${season}/${getBasePath()}`;
try {
const [bandsRes, gccRes, ndviRes] = await Promise.all([
fetch(`${base}/s2_bands/timeseries.json`),
fetch(`${base}/gcc/s2/timeseries.json`),
fetch(`${base}/ndvi/s2/timeseries.json`)
]);
timeseries = bandsRes.ok ? await bandsRes.json() : [];
gccTimeseries = gccRes.ok ? await gccRes.json() : [];
ndviTimeseries = ndviRes.ok ? await ndviRes.json() : [];
} catch {
timeseries = [];
gccTimeseries = [];
ndviTimeseries = [];
}
document.getElementById("bandPlots").innerHTML =
`<div class="plot-label">S2 NDVI</div><canvas id="plot_ndvi" class="plot"></canvas>` +
`<div class="plot-label">S2 GCC (Greenness Index)</div><canvas id="plot_gcc" class="plot"></canvas>` +
BANDS.map(b => `<div class="plot-label">${b.label}</div><canvas id="plot_${b.key}" class="plot"></canvas>`).join("");
const yearEnd = new Date(parseInt(season), 11, 31);
document.getElementById("dateSlider").max = Math.ceil((yearEnd - start) / 86400000);
drawAllPlots();
document.getElementById("dateDisplay").textContent = dateFromDays(parseInt(document.getElementById("dateSlider").value));
updateS2Imagery();
}
async function probeDataExists(sitename, s) {
try {
const res = await fetch(`../data/${sitename}/${s}/processed_aggressive_sigma20/s2_bands/timeseries.json`, { method: "HEAD" });
return res.ok;
} catch { return false; }
}
function getSiteBySitename(sitename) {
return window.sitesData?.features?.find(f => f.properties?.sitename === sitename);
}
async function setSiteSeason(newSite, newSeason) {
siteName = newSite;
season = newSeason;
start = new Date(parseInt(season), 0, 1);
const site = getSiteBySitename(newSite);
if (site?.geometry?.coordinates) {
const [lon, lat] = site.geometry.coordinates;
sitePosition = [lat, lon];
}
if (s2Map) { s2Map.setView(sitePosition, 12); if (s2Marker) s2Marker.setLatLng(sitePosition); }
document.getElementById("siteName").textContent = (site?.properties?.description || newSite);
document.getElementById("season").textContent = season;
const params = new URLSearchParams(location.search);
params.set("site", siteName);
params.set("season", season);
history.replaceState({}, "", `?${params}`);
await loadTimeseries();
const urlDate = params.get("date");
if (urlDate) document.getElementById("dateSlider").value = daysFromDate(urlDate);
}
async function init() {
try {
const res = await fetch("../data/sites.geojson");
window.sitesData = res.ok ? await res.json() : { features: [] };
} catch {
window.sitesData = { features: [] };
}
const features = window.sitesData.features || [];
for (const f of features) {
const sn = f.properties?.sitename;
if (!sn) continue;
const seasonsFromGeo = f.properties?.seasons ? Object.keys(f.properties.seasons).sort() : [];
const withData = [];
for (const s of seasonsFromGeo) {
if (await probeDataExists(sn, s)) withData.push(s);
}
if (withData.length) availableSiteSeasons[sn] = withData;
}
const availableSites = Object.keys(availableSiteSeasons);
const siteSelect = document.getElementById("siteSelect");
siteSelect.innerHTML = "";
(availableSites.length ? availableSites.sort() : ["innsbruck"]).forEach(sn => {
const opt = document.createElement("option");
opt.value = sn;
opt.textContent = sn;
siteSelect.appendChild(opt);
if (!availableSiteSeasons[sn]) availableSiteSeasons[sn] = ["2024"];
});
const urlSite = urlParams.get("site");
const urlSeason = urlParams.get("season");
const initialSite = (urlSite && availableSiteSeasons[urlSite]) ? urlSite : (availableSites[0] || "innsbruck");
const initialSeason = (urlSeason && (availableSiteSeasons[initialSite] || []).includes(urlSeason)) ? urlSeason : ((availableSiteSeasons[initialSite] || [])[0] || "2024");
siteSelect.value = initialSite;
document.getElementById("seasonSelect").innerHTML = (availableSiteSeasons[initialSite] || []).map(s =>
`<option value="${s}">${s}</option>`
).join("");
document.getElementById("seasonSelect").value = initialSeason;
document.getElementById("scenarioSelect").value = `${strategy}_${sigma || "20"}`;
const initSite = getSiteBySitename(initialSite);
if (initSite?.geometry?.coordinates) {
const [lon, lat] = initSite.geometry.coordinates;
sitePosition = [lat, lon];
}
const osmUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
s2Map = L.map("s2map", { zoomControl: false }).setView(sitePosition, 12)
.addLayer(L.tileLayer(osmUrl, { attribution: "OpenStreetMap", opacity: 0.4 }));
s2Marker = L.marker(sitePosition, { icon: L.divIcon({ className: "site-marker", html: "<div style='width:8px;height:8px;background:red;border:2px solid white;border-radius:50%;box-shadow:0 0 2px rgba(0,0,0,0.5);'></div>", iconSize: [8, 8] }) }).addTo(s2Map);
siteSelect.addEventListener("change", function() {
const sn = this.value;
const seas = availableSiteSeasons[sn] || [];
document.getElementById("seasonSelect").innerHTML = seas.map(s => `<option value="${s}">${s}</option>`).join("");
document.getElementById("seasonSelect").value = seas[0] || "2024";
setSiteSeason(sn, document.getElementById("seasonSelect").value);
});
document.getElementById("seasonSelect").addEventListener("change", function() {
setSiteSeason(siteSelect.value, this.value);
});
document.getElementById("scenarioSelect").addEventListener("change", function() {
const p = new URLSearchParams(location.search);
p.set("scenario", this.value);
window.location.search = p.toString();
});
await setSiteSeason(initialSite, initialSeason);
}
document.getElementById("dateSlider").addEventListener("input", function() {
document.getElementById("dateDisplay").textContent = dateFromDays(parseInt(this.value));
drawAllPlots();
updateS2Imagery();
});
function closestFilename(dateStr) {
const target = new Date(dateStr);
const withData = timeseries.filter(t => t.filename);
if (!withData.length) return null;
const closest = withData.reduce((c, t) =>
Math.abs(new Date(t.date) - target) < Math.abs(new Date(c.date) - target) ? t : c
);
return closest.filename;
}
function transformBounds(bbox, fromCRS) {
const sw = proj4(fromCRS, "EPSG:4326", [bbox[0], bbox[1]]);
const ne = proj4(fromCRS, "EPSG:4326", [bbox[2], bbox[3]]);
return [[sw[1], sw[0]], [ne[1], ne[0]]];
}
async function loadS2Geotiff(filename) {
const path = `../data/${siteName}/${season}/${getBasePath()}/s2/${filename}`;
const tiff = await GeoTIFF.fromArrayBuffer(await (await fetch(path)).arrayBuffer());
const image = await tiff.getImage();
const rasters = await image.readRasters();
const width = image.getWidth(), height = image.getHeight();
const bbox = image.getBoundingBox();
const geoKeys = image.getGeoKeys();
const crsCode = geoKeys.ProjectedCSTypeGeoKey ? `EPSG:${geoKeys.ProjectedCSTypeGeoKey}` :
(geoKeys.GeographicTypeGeoKey !== 4326 ? `EPSG:${geoKeys.GeographicTypeGeoKey}` : "EPSG:4326");
const [blue, green, red] = [0, 1, 2].map(i => Array.from(rasters[i]));
const normalize = (arr) => {
let min = Infinity, max = -Infinity;
for (const v of arr) if (!isNaN(v) && v > 0) { min = Math.min(min, v); max = Math.max(max, v); }
return arr.map(v => Math.max(0, Math.min(255, ((v - min) / (max - min || 1)) * 255)));
};
const [rN, gN, bN] = [red, green, blue].map(normalize);
const canvas = Object.assign(document.createElement("canvas"), { width, height });
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
const imgData = ctx.createImageData(width, height);
for (let i = 0; i < rN.length; i++) {
const idx = i * 4;
if (rN[i] === 0 && gN[i] === 0 && bN[i] === 0) imgData.data[idx + 3] = 0;
else { imgData.data[idx] = rN[i]; imgData.data[idx + 1] = gN[i]; imgData.data[idx + 2] = bN[i]; imgData.data[idx + 3] = 255; }
}
ctx.putImageData(imgData, 0, 0);
const bounds = crsCode === "EPSG:4326" ? [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] : transformBounds(bbox, crsCode);
return { dataUrl: canvas.toDataURL(), bounds };
}
async function updateS2Imagery() {
const dateStr = dateFromDays(parseInt(document.getElementById("dateSlider").value));
const filename = closestFilename(dateStr);
if (!filename || !s2Map) {
if (s2Overlay) { s2Map.removeLayer(s2Overlay); s2Overlay = null; }
document.getElementById("s2rgbdate").textContent = "";
return;
}
try {
const { dataUrl, bounds } = await loadS2Geotiff(filename);
if (s2Overlay) s2Map.removeLayer(s2Overlay);
s2Overlay = L.imageOverlay(dataUrl, bounds, { opacity: 0.95 }).addTo(s2Map);
s2Map.fitBounds(bounds);
const d = filename.split("_")[0];
document.getElementById("s2rgbdate").textContent = `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}`;
} catch (e) {
if (s2Overlay) { s2Map.removeLayer(s2Overlay); s2Overlay = null; }
document.getElementById("s2rgbdate").textContent = "";
}
}
init();
</script>
</body>
</html>