efast-phenocam-validation/webapp/metrics.html
2026-05-03 19:43:54 +02:00

230 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Metrics</title>
<style>
body { margin: 0; font-family: sans-serif; }
.nav { margin-bottom: 15px; font-size: 14px; }
.nav a { margin-right: 12px; color: #0066cc; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.nav a.active { font-weight: bold; }
.container { max-width: 1100px; margin: 0 auto; padding: 20px; }
.selectors { margin-bottom: 20px; }
.selectors select { padding: 5px 10px; font-size: 14px; margin-right: 15px; }
h1 { font-size: 22px; }
h2 { font-size: 16px; margin-top: 24px; color: #333; }
table { border-collapse: collapse; width: 100%; font-size: 13px; margin-bottom: 12px; }
th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; }
th { background: #f5f5f5; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.section-note { font-size: 12px; color: #555; margin: -6px 0 8px 0; max-width: 720px; line-height: 1.45; }
.section-note code { background: #f1f1f1; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
.intro { font-size: 13px; color: #333; background: #fafafa; border: 1px solid #e5e5e5;
padding: 10px 12px; border-radius: 4px; margin-bottom: 18px; line-height: 1.5; }
.intro ul { margin: 6px 0 0 18px; padding: 0; }
.intro li { margin-bottom: 2px; }
.empty { color: #666; font-style: italic; }
.err { color: #a00; }
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="index.html">Full</a>
<a href="preselection.html">Pre-selection</a>
<a href="prepared.html">Prepared</a>
<a href="fusion.html">Fusion</a>
<a href="postprocessed.html">Postprocessed</a>
<a href="metrics.html" class="active">Metrics</a>
<a href="phenology.html">Phenology</a>
</div>
<h1 id="siteName">Metrics</h1>
<div class="selectors">
<label>Site:</label>
<select id="siteSelect"></select>
<label>Season:</label>
<select id="seasonSelect"></select>
</div>
<div id="content"></div>
</div>
<script>
/** Shown in the UI; pearson_r, rmse, mae, n_samples remain in metrics.json only. */
const DISPLAY_METRIC_COLS = ["r_squared", "nrmse", "nse_pc"];
const DISPLAY_METRIC_LABELS = {
r_squared: "R²",
nrmse: "nRMSE",
nse_pc: "NSE_PC",
};
function mv(m, c) {
return c === "nse_pc" ? (m.nse_pc ?? m.nse) : m[c];
}
function fmtMetric(col, v) {
if (v == null || typeof v !== "number") return "—";
if (col === "r_squared" || col === "nse_pc") return v.toFixed(3);
if (col === "nrmse") return v.toFixed(4);
return fmt(v);
}
let siteName = "innsbruck", season = "2024";
let availableSiteSeasons = {};
const urlParams = new URLSearchParams(location.search);
async function probeMetrics(sn, s) {
try {
const res = await fetch(`data/${sn}/${s}/metrics.json`, { method: "HEAD" });
return res.ok;
} catch { return false; }
}
function fmt(v) {
if (v == null || typeof v !== "number") return "—";
return Number.isInteger(v) ? String(v) : v.toFixed(4);
}
function tableSection(title, obj) {
const heading = title ? `<h2>${title}</h2>` : "";
if (!obj || typeof obj !== "object" || !Object.keys(obj).length) {
return `${heading}<p class="empty">No data</p>`;
}
const keys = Object.keys(obj).sort();
let head = `<tr><th>Scenario</th>${DISPLAY_METRIC_COLS.map((c) => `<th>${DISPLAY_METRIC_LABELS[c]}</th>`).join("")}</tr>`;
const rows = keys.map((k) => {
const m = obj[k] || {};
return `<tr><td>${k}</td>${DISPLAY_METRIC_COLS.map((c) => `<td class="num">${fmtMetric(c, mv(m, c))}</td>`).join("")}</tr>`;
}).join("");
return `${heading}<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>${DISPLAY_METRIC_COLS.map((c) => `<td class="num">${fmtMetric(c, 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>${DISPLAY_METRIC_COLS.map((c) => `<th>${DISPLAY_METRIC_LABELS[c]}</th>`).join("")}</tr>`;
return `<h2>Baselines (temporal vs PhenoCam)</h2><table>${head}${rows.join("")}</table>`;
}
function render(data) {
const el = document.getElementById("content");
if (!data) {
el.innerHTML = `<p class="err">Could not load metrics.json</p>`;
return;
}
let html = "";
html += `
<div class="intro">
All metrics compare a greenness index (GCC) from satellite products against PhenoCam
ground-truth GCC at the site's 3×3 pixel window.
<ul>
<li><b>BtI</b> (<i>Bands-then-Index</i>): fuse S2/S3 reflectance, then compute GCC from the fused bands.</li>
<li><b>ItB</b> (<i>Index-then-Bands</i>): compute GCC from S2 and S3 first, then fuse the GCC rasters.</li>
<li>Scenarios combine a cloud-screening <b>strategy</b> (<code>aggressive</code> / <code>nonaggressive</code>)
and an EFAST fusion <b>σ</b> (<code>sigma20</code> / <code>sigma30</code>).</li>
<li>Tables list <b>R²</b> (fit vs PhenoCam), <b>nRMSE</b> (RMSE normalised by PhenoCam variability),
and <b>NSE_PC</b> (NashSutcliffe using PhenoCam as reference). Pearson <i>r</i>, RMSE, MAE, and sample counts stay in <code>metrics.json</code>.</li>
</ul>
</div>`;
if (data.phenocam_stats) {
html += `<h2>PhenoCam</h2>`;
html += `<p class="section-note">Summary statistics of the PhenoCam GCC timeseries used as ground truth for this site and season.</p>`;
html += `<table><tr><th>mean</th><th>std</th><th>min</th><th>max</th><th>n</th></tr><tr>`;
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>`;
}
const baselineHtml = baselineSection(data.baseline);
if (baselineHtml) {
html += `<h2>Baselines (temporal vs PhenoCam)</h2>`;
html += `<p class="section-note">Reference GCC series <i>before</i> any fusion: raw S2 (all dates and cloud-screened per strategy), S3 composite per strategy, and a Whittaker-smoothed S2 series (λ=400). Useful to see what fusion has to beat.</p>`;
html += baselineHtml.replace(/^<h2>[^<]*<\/h2>/, "");
}
html += `<h2>Temporal (vs PhenoCam)</h2>`;
html += `<p class="section-note">Per-scenario agreement between the fusion GCC <b>timeseries</b> at the site 3×3 window and the PhenoCam GCC timeseries, across all matched dates. Scenarios ending in <code>_itb</code> are Index-then-Bands; the others are Bands-then-Index.</p>`;
html += tableSection("", data.temporal);
el.innerHTML = html || `<p class="empty">Empty metrics file</p>`;
}
async function load() {
try {
const res = await fetch(`data/${siteName}/${season}/metrics.json`);
render(res.ok ? await res.json() : null);
} catch {
render(null);
}
const site = window.sitesData?.features?.find((f) => f.properties?.sitename === siteName);
document.getElementById("siteName").textContent = (site?.properties?.description || siteName) + " — " + season;
urlParams.set("site", siteName);
urlParams.set("season", season);
history.replaceState({}, "", `?${urlParams}`);
}
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 probeMetrics(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;
siteName = initialSite;
season = initialSeason;
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";
siteName = sn;
season = document.getElementById("seasonSelect").value;
load();
});
document.getElementById("seasonSelect").addEventListener("change", function () {
season = this.value;
load();
});
await load();
}
init();
</script>
</body>
</html>