This commit is contained in:
Felix Delattre 2026-04-20 08:55:35 +02:00
parent d50a0fcbb1
commit 94f910d978
3 changed files with 227 additions and 35 deletions

View file

@ -3,13 +3,24 @@
import json import json
import numpy as np import numpy as np
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timedelta
from scipy import sparse
from scipy.sparse.linalg import spsolve
from scipy.stats import pearsonr 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 metrics_indices import BLUE_BAND, GREEN_BAND, RED_BAND from metrics_indices import BLUE_BAND, GREEN_BAND, RED_BAND
WHITTAKER_LAMBDA_DAYS_SQ = 400.0
def _norm_date_key(s):
if s is None:
return None
t = str(s).strip()
return t.split("T")[0][:10] if "T" in t else t[:10]
def load_timeseries(filepath): def load_timeseries(filepath):
"""Load JSON timeseries and return dict mapping date -> value.""" """Load JSON timeseries and return dict mapping date -> value."""
@ -22,14 +33,24 @@ def load_timeseries(filepath):
def match_dates(fusion_ts, phenocam_ts): def match_dates(fusion_ts, phenocam_ts):
"""Match dates between timeseries, return aligned numpy arrays (filter None values).""" """Match dates between timeseries, return aligned numpy arrays (filter None values)."""
common_dates = set(fusion_ts.keys()) & set(phenocam_ts.keys())
def _bundle(m):
out = {}
for k, v in m.items():
nk = _norm_date_key(k)
if nk and nk not in out:
out[nk] = v
return out
fa, pa = _bundle(fusion_ts), _bundle(phenocam_ts)
common_dates = set(fa) & set(pa)
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 = fa[date]
phenocam_val = phenocam_ts[date] phenocam_val = pa[date]
if fusion_val is not None and phenocam_val is not None: if fusion_val is not None and phenocam_val is not None:
fusion_vals.append(fusion_val) fusion_vals.append(fusion_val)
phenocam_vals.append(phenocam_val) phenocam_vals.append(phenocam_val)
@ -94,19 +115,21 @@ 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.""" """Temporal metrics vs PhenoCam (nse_pc; nse is the same value)."""
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
n_pc = nse(phenocam_vals, fusion_vals)
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),
"rmse": rmse(phenocam_vals, fusion_vals), "rmse": rmse(phenocam_vals, fusion_vals),
"mae": mae(phenocam_vals, fusion_vals), "mae": mae(phenocam_vals, fusion_vals),
"nrmse": nrmse(phenocam_vals, fusion_vals), "nrmse": nrmse(phenocam_vals, fusion_vals),
"nse": nse(phenocam_vals, fusion_vals), "nse_pc": n_pc,
"nse": n_pc,
"n_samples": len(fusion_vals), "n_samples": len(fusion_vals),
"date_range": {"start": dates[0], "end": dates[-1]} if dates else None, "date_range": {"start": dates[0], "end": dates[-1]} if dates else None,
} }
@ -129,6 +152,59 @@ def calculate_phenocam_stats(phenocam_ts):
} }
def _s2_kept_date_set(base: Path, strategy: str) -> set:
path = base / "raw" / "preselection" / "s2_preselection.json"
if not path.exists():
return set()
with open(path) as f:
rows = json.load(f)
key = f"excluded_{strategy}"
out = set()
for e in rows:
if e.get(key):
continue
nk = _norm_date_key(e.get("date"))
if nk:
out.add(nk)
return out
def _whittaker_smooth_dict(obs_dates, obs_values, lam: float, n_min: int = 3):
"""Daily Whittaker (weights 1 at obs); returns {YYYY-MM-DD: z}."""
pairs = [
(_norm_date_key(d), float(v))
for d, v in zip(obs_dates, obs_values)
if v is not None and _norm_date_key(d)
]
if len(pairs) < 2:
return {}
days = sorted({p[0] for p in pairs})
t0 = datetime.strptime(days[0], "%Y-%m-%d").date()
t1 = datetime.strptime(days[-1], "%Y-%m-%d").date()
n = (t1 - t0).days + 1
if n < n_min:
return {}
w = np.zeros(n)
y = np.zeros(n)
for dk, val in pairs:
i = (datetime.strptime(dk, "%Y-%m-%d").date() - t0).days
if 0 <= i < n:
w[i] = 1.0
y[i] = val
D = sparse.diags(
[1.0, -2.0, 1.0], [0, 1, 2], shape=(n - 2, n), format="csc", dtype=np.float64
)
H = D.T @ D
Wm = sparse.diags(w.astype(np.float64), format="csc")
z = spsolve(Wm + lam * H, w * y)
out = {}
for i in range(n):
out[(t0 + timedelta(days=i)).isoformat()] = float(z[i])
return out
def _get_spatial_stats_from_raster(raster_file, site_position): def _get_spatial_stats_from_raster(raster_file, site_position):
"""Extract spatial statistics (mean, std, min, max) from GCC raster in 3x3 window.""" """Extract spatial statistics (mean, std, min, max) from GCC raster in 3x3 window."""
try: try:
@ -316,15 +392,52 @@ def calculate_all_metrics(season, site_name, site_position):
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) baseline = {}
s2_ts_path = ( s2_ts = {}
base / "processed_aggressive_sigma20" / "gcc" / "s2" / "timeseries.json" for sub in ("processed_aggressive_sigma20", "processed_nonaggressive_sigma20"):
) p = base / sub / "gcc" / "s2" / "timeseries.json"
s2_ts = load_timeseries(s2_ts_path) if p.exists():
s2_ts = load_timeseries(p)
if s2_ts:
break
if s2_ts: if s2_ts:
s2_metrics = calculate_temporal_metrics(s2_ts, phenocam_ts) m0 = calculate_temporal_metrics(s2_ts, phenocam_ts)
if s2_metrics: if m0:
results["baseline"] = {"s2": s2_metrics} baseline["s2"] = m0
for strategy in ("aggressive", "nonaggressive"):
kept = _s2_kept_date_set(base, strategy)
filtered = [
(k, v)
for k, v in sorted(
s2_ts.items(), key=lambda x: _norm_date_key(x[0]) or ""
)
if _norm_date_key(k) in kept and v is not None
]
if not filtered:
continue
sub_ts = dict(filtered)
mcf = calculate_temporal_metrics(sub_ts, phenocam_ts)
if mcf:
baseline.setdefault("s2_cloudfree", {})[strategy] = mcf
obs_d, obs_v = zip(*filtered)
smooth = _whittaker_smooth_dict(obs_d, obs_v, WHITTAKER_LAMBDA_DAYS_SQ)
if smooth:
mw = calculate_temporal_metrics(smooth, phenocam_ts)
if mw:
baseline.setdefault("s2_whittaker_lambda400", {})[strategy] = mw
for strategy in ("aggressive", "nonaggressive"):
p = base / f"processed_{strategy}_sigma20" / "gcc" / "s3" / "timeseries.json"
if not p.exists():
continue
s3_ts = load_timeseries(p)
if s3_ts:
m3 = calculate_temporal_metrics(s3_ts, phenocam_ts)
if m3:
baseline.setdefault("s3", {})[strategy] = m3
if baseline:
results["baseline"] = baseline
# Calculate fusion metrics for each scenario # Calculate fusion metrics for each scenario
for strategy in ["aggressive", "nonaggressive"]: for strategy in ["aggressive", "nonaggressive"]:
@ -378,15 +491,17 @@ def calculate_all_metrics(season, site_name, site_position):
if spatial_metrics: if spatial_metrics:
results["spatial"][scenario_name] = spatial_metrics results["spatial"][scenario_name] = spatial_metrics
# Add summary # Add summary (primary: NSE vs PhenoCam; R² kept for comparison)
if results["temporal"]: if results["temporal"]:
best_temporal = max( ti = list(results["temporal"].items())
results["temporal"].items(),
key=lambda x: x[1].get("r_squared", -1) def _score(k):
if x[1].get("r_squared") is not None return lambda x: x[1].get(k) if x[1].get(k) is not None else float("-inf")
else -1,
) results["summary"] = {
results["summary"] = {"best_temporal_scenario": best_temporal[0]} "best_temporal_scenario": max(ti, key=_score("nse_pc"))[0],
"best_temporal_scenario_by_r2": max(ti, key=_score("r_squared"))[0],
}
if results["spatial"]: if results["spatial"]:
best_spatial = max( best_spatial = max(

View file

@ -561,8 +561,8 @@
const scenarios = ["aggressive_sigma20", "aggressive_sigma30", "nonaggressive_sigma20", "nonaggressive_sigma30"]; const scenarios = ["aggressive_sigma20", "aggressive_sigma30", "nonaggressive_sigma20", "nonaggressive_sigma30"];
const scenarioNames = ["Aggressive σ20", "Aggressive σ30", "Non-aggressive σ20", "Non-aggressive σ30"]; const scenarioNames = ["Aggressive σ20", "Aggressive σ30", "Non-aggressive σ20", "Non-aggressive σ30"];
const metrics = ["pearson_r", "r_squared", "rmse", "mae", "nrmse", "nse"]; const metrics = ["pearson_r", "r_squared", "rmse", "mae", "nrmse", "nse_pc"];
const metricLabels = { pearson_r: "r", r_squared: "R²", rmse: "RMSE", mae: "MAE", nrmse: "nRMSE", nse: "NSE" }; const metricLabels = { pearson_r: "r", r_squared: "R²", rmse: "RMSE", mae: "MAE", nrmse: "nRMSE", nse_pc: "NSE_PC" };
let html = "<table style='width:100%; border-collapse:collapse; font-size:11px;'>"; let html = "<table style='width:100%; border-collapse:collapse; font-size:11px;'>";
html += "<thead><tr style='background:#f5f5f5; border-bottom:2px solid #ccc;'>"; html += "<thead><tr style='background:#f5f5f5; border-bottom:2px solid #ccc;'>";
@ -576,8 +576,8 @@
html += `<tr style='border-bottom:2px solid #ccc; background:#f9f9f9;'>`; html += `<tr style='border-bottom:2px solid #ccc; background:#f9f9f9;'>`;
html += `<td style='padding:6px 8px; font-weight:600;'>S2 (baseline)</td>`; html += `<td style='padding:6px 8px; font-weight:600;'>S2 (baseline)</td>`;
metrics.forEach(m => { metrics.forEach(m => {
const val = data[m]; const val = m === "nse_pc" ? (data.nse_pc ?? data.nse) : data[m];
const fmt = val !== null && val !== undefined ? (m === "pearson_r" || m === "r_squared" || m === "nse" ? val.toFixed(3) : val.toFixed(4)) : "—"; const fmt = val !== null && val !== undefined ? (m === "pearson_r" || m === "r_squared" || m === "nse_pc" ? val.toFixed(3) : val.toFixed(4)) : "—";
html += `<td style='padding:6px 8px; text-align:right; font-family:monospace;'>${fmt}</td>`; html += `<td style='padding:6px 8px; text-align:right; font-family:monospace;'>${fmt}</td>`;
}); });
html += "</tr>"; html += "</tr>";
@ -590,8 +590,8 @@
html += `<tr style='border-bottom:1px solid #eee;'>`; html += `<tr style='border-bottom:1px solid #eee;'>`;
html += `<td style='padding:6px 8px; font-weight:500;'>${scenarioNames[i]}</td>`; html += `<td style='padding:6px 8px; font-weight:500;'>${scenarioNames[i]}</td>`;
metrics.forEach(m => { metrics.forEach(m => {
const val = data[m]; const val = m === "nse_pc" ? (data.nse_pc ?? data.nse) : data[m];
const fmt = val !== null && val !== undefined ? (m === "pearson_r" || m === "r_squared" || m === "nse" ? val.toFixed(3) : val.toFixed(4)) : "—"; const fmt = val !== null && val !== undefined ? (m === "pearson_r" || m === "r_squared" || m === "nse_pc" ? val.toFixed(3) : val.toFixed(4)) : "—";
html += `<td style='padding:6px 8px; text-align:right; font-family:monospace;'>${fmt}</td>`; html += `<td style='padding:6px 8px; text-align:right; font-family:monospace;'>${fmt}</td>`;
}); });
html += "</tr>"; html += "</tr>";

View file

@ -18,6 +18,7 @@
th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; } th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; }
th { background: #f5f5f5; } th { background: #f5f5f5; }
td.num { text-align: right; font-variant-numeric: tabular-nums; } td.num { text-align: right; font-variant-numeric: tabular-nums; }
.compare-note { font-size: 12px; color: #555; margin: 0 0 8px 0; max-width: 720px; }
.empty { color: #666; font-style: italic; } .empty { color: #666; font-style: italic; }
.err { color: #a00; } .err { color: #a00; }
</style> </style>
@ -42,7 +43,12 @@
<div id="content"></div> <div id="content"></div>
</div> </div>
<script> <script>
const METRIC_COLS = ["pearson_r", "r_squared", "rmse", "mae", "nrmse", "nse", "n_samples"]; const METRIC_COLS = ["pearson_r", "r_squared", "rmse", "mae", "nrmse", "nse_pc", "n_samples"];
/** Spatial fusion metrics in metrics.json (no RMSE block at site level). */
const SPATIAL_METRIC_COLS = ["pearson_r", "r_squared", "n_samples"];
function mv(m, c) {
return c === "nse_pc" ? (m.nse_pc ?? m.nse) : m[c];
}
let siteName = "innsbruck", season = "2024"; let siteName = "innsbruck", season = "2024";
let availableSiteSeasons = {}; let availableSiteSeasons = {};
const urlParams = new URLSearchParams(location.search); const urlParams = new URLSearchParams(location.search);
@ -67,11 +73,75 @@
let head = `<tr><th>Scenario</th>${METRIC_COLS.map((c) => `<th>${c}</th>`).join("")}</tr>`; let head = `<tr><th>Scenario</th>${METRIC_COLS.map((c) => `<th>${c}</th>`).join("")}</tr>`;
const rows = keys.map((k) => { const rows = keys.map((k) => {
const m = obj[k] || {}; const m = obj[k] || {};
return `<tr><td>${k}</td>${METRIC_COLS.map((c) => `<td class="num">${fmt(m[c])}</td>`).join("")}</tr>`; return `<tr><td>${k}</td>${METRIC_COLS.map((c) => `<td class="num">${fmt(mv(m, c))}</td>`).join("")}</tr>`;
}).join(""); }).join("");
return `<h2>${title}</h2><table>${head}${rows}</table>`; return `<h2>${title}</h2><table>${head}${rows}</table>`;
} }
/** Pair BtI keys (`aggressive_sigma20`) with ItB (`aggressive_sigma20_itb`). */
function btiItbPairs(obj) {
if (!obj || typeof obj !== "object") return [];
const pairs = [];
for (const itbKey of Object.keys(obj)) {
if (!itbKey.endsWith("_itb")) continue;
const btiKey = itbKey.slice(0, -"_itb".length);
const bti = obj[btiKey];
const itb = obj[itbKey];
if (!bti || !itb) continue;
pairs.push({ label: btiKey, bti, itb });
}
pairs.sort((a, b) => a.label.localeCompare(b.label));
return pairs;
}
function fmtDelta(btiM, itbM, col) {
const a = mv(btiM, col);
const b = mv(itbM, col);
if (a == null || b == null || typeof a !== "number" || typeof b !== "number") return "—";
return fmt(b - a);
}
function btiItbCompareSection(title, obj, blurb, metricCols = METRIC_COLS) {
const pairs = btiItbPairs(obj);
if (!pairs.length) return "";
const subHead = metricCols.map(
() => `<th class="num">BtI</th><th class="num">ItB</th><th class="num">Δ</th>`
).join("");
const head =
`<tr><th rowspan="2">Scenario</th>${metricCols.map((c) => `<th colspan="3">${c}</th>`).join("")}</tr>` +
`<tr>${subHead}</tr>`;
const rows = pairs
.map((p) => {
const cells = metricCols.map((c) => {
const vB = fmt(mv(p.bti, c));
const vI = fmt(mv(p.itb, c));
const d = fmtDelta(p.bti, p.itb, c);
return `<td class="num">${vB}</td><td class="num">${vI}</td><td class="num">${d}</td>`;
}).join("");
return `<tr><td>${p.label}</td>${cells}</tr>`;
})
.join("");
return `<h2>${title}</h2><p class="compare-note">${blurb}</p><table>${head}${rows}</table>`;
}
function baselineSection(b) {
if (!b || typeof b !== "object") return "";
const rows = [];
const pushRow = (label, m) => {
if (!m || typeof m !== "object") return;
rows.push(`<tr><td>${label}</td>${METRIC_COLS.map((c) => `<td class="num">${fmt(mv(m, c))}</td>`).join("")}</tr>`);
};
pushRow("S2 GCC (all acquisitions)", b.s2);
for (const strat of ["aggressive", "nonaggressive"]) {
pushRow(`S3 composite GCC (${strat})`, b.s3?.[strat]);
pushRow(`S2 GCC cloud-screened (${strat})`, b.s2_cloudfree?.[strat]);
pushRow(`S2 Whittaker λ=400 (${strat})`, b.s2_whittaker_lambda400?.[strat]);
}
if (!rows.length) return "";
const head = `<tr><th>Baseline</th>${METRIC_COLS.map((c) => `<th>${c}</th>`).join("")}</tr>`;
return `<h2>Baselines (temporal vs PhenoCam)</h2><table>${head}${rows.join("")}</table>`;
}
function render(data) { function render(data) {
const el = document.getElementById("content"); const el = document.getElementById("content");
if (!data) { if (!data) {
@ -84,12 +154,19 @@
const p = data.phenocam_stats; const p = data.phenocam_stats;
html += `<td class="num">${fmt(p.mean)}</td><td class="num">${fmt(p.std)}</td><td class="num">${fmt(p.min)}</td><td class="num">${fmt(p.max)}</td><td class="num">${fmt(p.n_samples)}</td></tr></table>`; html += `<td class="num">${fmt(p.mean)}</td><td class="num">${fmt(p.std)}</td><td class="num">${fmt(p.min)}</td><td class="num">${fmt(p.max)}</td><td class="num">${fmt(p.n_samples)}</td></tr></table>`;
} }
if (data.baseline && data.baseline.s2) { html += baselineSection(data.baseline);
html += `<h2>Baseline S2 (temporal)</h2><table><tr>${METRIC_COLS.map((c) => `<th>${c}</th>`).join("")}</tr><tr>`; html += btiItbCompareSection(
const b = data.baseline.s2; "Temporal: BtI vs ItB (paired)",
html += METRIC_COLS.map((c) => `<td class="num">${fmt(b[c])}</td>`).join("") + "</tr></table>"; data.temporal,
} "Δ = ItB BtI. For Pearson r, R², and NSE (%), positive Δ means ItB is higher. For RMSE, MAE, and NRMSE, negative Δ means ItB is better (lower error)."
);
html += tableSection("Temporal (vs PhenoCam)", data.temporal); html += tableSection("Temporal (vs PhenoCam)", data.temporal);
html += btiItbCompareSection(
"Spatial: BtI vs ItB (paired)",
data.spatial,
"Δ = ItB BtI. For Pearson r and R², positive Δ means ItB is higher.",
SPATIAL_METRIC_COLS
);
html += tableSection("Spatial (3×3 fusion mean vs PhenoCam)", data.spatial); html += tableSection("Spatial (3×3 fusion mean vs PhenoCam)", data.spatial);
if (data.summary) { if (data.summary) {
html += `<h2>Summary</h2><pre style="font-size:13px;background:#f9f9f9;padding:10px;">${JSON.stringify(data.summary, null, 2)}</pre>`; html += `<h2>Summary</h2><pre style="font-size:13px;background:#f9f9f9;padding:10px;">${JSON.stringify(data.summary, null, 2)}</pre>`;