efast-phenocam-validation/webapp/metrics.html
Felix Delattre 374be6865d Foo
2026-05-16 18:01:46 +02:00

367 lines
18 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; }
h2:first-of-type { margin-top: 8px; }
h3 { font-size: 14px; margin: 14px 0 6px 0; color: #444; font-weight: 600; }
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; }
.fusion-block table { margin-bottom: 4px; }
.fusion-block table + table { margin-top: 12px; }
.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-short { margin-bottom: 0; }
details.definitions { margin-top: 28px; font-size: 13px; border: 1px solid #e5e5e5; border-radius: 4px; padding: 8px 12px; background: #fafafa; }
details.definitions summary { cursor: pointer; font-weight: 600; color: #333; }
details.definitions ul { margin: 8px 0 0 18px; padding: 0; }
details.definitions li { margin-bottom: 4px; }
.scenario-key { font-size: 11px; color: #666; font-weight: normal; }
.empty { color: #666; font-style: italic; }
.err { color: #a00; }
details.how-read {
font-size: 12px; color: #333; line-height: 1.5; max-width: 820px; margin: 0 0 18px 0;
padding: 8px 12px 10px; border: 1px solid #ccd; border-radius: 4px; background: #f8fafc;
}
details.how-read summary {
cursor: pointer; font-weight: 600; font-size: 13px; color: #111; margin-bottom: 0;
}
details.how-read ol { margin: 10px 0 0; padding-left: 1.35rem; }
details.how-read li { margin-bottom: 7px; }
details.how-read li:last-child { margin-bottom: 0; }
</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="gap_validation.html">Gap validation</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² vs mean",
nrmse: "nRMSE",
nse_pc: "NSE_PC",
};
const FUSION_BTI_ROWS = [
["aggressive_sigma20", "Aggressive", 20],
["aggressive_sigma30", "Aggressive", 30],
["nonaggressive_sigma20", "Non-aggressive", 20],
["nonaggressive_sigma30", "Non-aggressive", 30],
];
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 fusionMeanResidual(m) {
const x = m?.residual_vs_phenocam?.mean;
const n = Number(x);
return Number.isFinite(n) ? n : null;
}
function fusionSubTableRows(temporal, keysWithLabels, includeMeanResid) {
const parts = [];
for (const [key, stratLabel, sig] of keysWithLabels) {
const m = temporal[key];
if (!m) continue;
const mr = fusionMeanResidual(m);
const meanCell = includeMeanResid
? `<td class="num">${mr !== null ? mr.toFixed(3) : "—"}</td>`
: "";
parts.push(
`<tr><td>${stratLabel}, σ=${sig} <span class="scenario-key">(${key})</span></td>${DISPLAY_METRIC_COLS.map((c) => `<td class="num">${fmtMetric(c, mv(m, c))}</td>`).join("")}${meanCell}</tr>`
);
}
return parts;
}
function fusionTables(temporal) {
if (!temporal || typeof temporal !== "object") {
return `<p class="empty">No fusion temporal data</p>`;
}
const itbRows = FUSION_BTI_ROWS.map(([k, s, sig]) => [`${k}_itb`, s, sig]);
const allKeys = [...FUSION_BTI_ROWS.map((r) => r[0]), ...itbRows.map((r) => r[0])];
let showMean = false;
for (const k of allKeys) {
if (fusionMeanResidual(temporal[k]) !== null) {
showMean = true;
break;
}
}
const btiBody = fusionSubTableRows(temporal, FUSION_BTI_ROWS, showMean);
const itbBody = fusionSubTableRows(temporal, itbRows, showMean);
if (!btiBody.length && !itbBody.length) {
return `<p class="empty">No fusion scenarios in temporal</p>`;
}
const meanTh = showMean ? `<th class="num">Mean resid.</th>` : "";
const head = `<tr><th>Setting</th>${DISPLAY_METRIC_COLS.map((c) => `<th class="num">${DISPLAY_METRIC_LABELS[c]}</th>`).join("")}${meanTh}</tr>`;
let h = `<div class="fusion-block">`;
if (btiBody.length) {
h += `<h3>Bands-then-Index (BtI)</h3>`;
h += `<table>${head}${btiBody.join("")}</table>`;
}
if (itbBody.length) {
h += `<h3>Index-then-Bands (ItB)</h3>`;
h += `<table>${head}${itbBody.join("")}</table>`;
}
h += `</div>`;
return h;
}
/** Returns only &lt;table&gt;…&lt;/table&gt; or empty string (no heading). */
function baselineTable(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 class="num">${DISPLAY_METRIC_LABELS[c]}</th>`).join("")}</tr>`;
return `<table>${head}${rows.join("")}</table>`;
}
function fmtFixed3(v) {
const n = Number(v);
return Number.isFinite(n) ? n.toFixed(3) : "—";
}
function derivedSection(d) {
if (!d) return "";
const dn = d.delta_nse_pc_sigma20_minus_sigma30;
const paired = d.bti_vs_itb_mean_residual || [];
if (!dn && !paired.length) return "";
let h = `<h2>Summaries</h2>`;
h += `<p class="section-note">Same numbers as Fusion, condensed. First table: which σ fits PhenoCam better (NSE_PC only). Second: mean bias BtI vs ItB.</p>`;
if (dn) {
h += `<p class="section-note"><b>ΔNSE_PC</b> = NSE_PC(σ20) NSE_PC(σ30). <b>+</b> → σ20 better. <b></b> → σ30 better.</p>`;
h += `<table><tr><th>Mode</th><th>Strategy</th><th class="num">ΔNSE_PC</th></tr>`;
let anyDelta = false;
for (const mode of ["bti", "itb"]) {
for (const strat of ["aggressive", "nonaggressive"]) {
const v = dn[mode]?.[strat];
if (Number.isFinite(Number(v))) anyDelta = true;
h += `<tr><td>${mode.toUpperCase()}</td><td>${strat}</td><td class="num">${fmtFixed3(v)}</td></tr>`;
}
}
h += `</table>`;
if (!anyDelta) {
h += `<p class="section-note">ΔNSE_PC needs both σ20 and σ30 fusion rows in <code>temporal</code> (BtI and ItB). Re-run <code>metrics_stats</code>.</p>`;
}
}
if (paired.length) {
h += `<p class="section-note">Mean(fused PhenoCam) per row. <b>+</b> / <b></b> = average over / under PhenoCam. Closer to <b>0</b> in a column = less bias for that workflow.</p>`;
h += `<table><tr><th>Strategy</th><th>σ</th><th class="num">Mean residual BtI</th><th class="num">Mean residual ItB</th></tr>`;
for (const row of paired) {
h += `<tr><td>${row.strategy}</td><td>${row.sigma}</td><td class="num">${fmtFixed3(row.mean_residual_bti)}</td><td class="num">${fmtFixed3(row.mean_residual_itb)}</td></tr>`;
}
h += `</table>`;
}
return h;
}
function howToReadBlock() {
return `<details class="how-read">
<summary>How to read</summary>
<ol>
<li>All scores are satellite or fusion <b>GCC</b> vs <b>PhenoCam GCC</b> at the site 3×3 window, <b>same calendar days</b> only. Extra stats: <code>metrics.json</code>.</li>
<li><b>R² vs mean</b> and <b>NSE_PC</b> are the same value (1 SS<sub>res</sub>/SS<sub>tot</sub> vs predicting mean PhenoCam each day); not (Pearson <i>r</i>)²; can be negative. Higher = better. <b>nRMSE</b>: lower = better.</li>
<li><b>Fusion:</b> same row number in BtI and in ItB = same screening + same σ — compare left/right. Down one block = change screening or σ.</li>
<li><b>Mean resid.</b> (if present): mean(fused PhenoCam). Sign = average bias; use R² vs mean / nRMSE / NSE_PC for overall fit.</li>
<li><b>Summaries:</b> ΔNSE_PC = NSE at σ20 minus NSE at σ30 (+ means σ20 wins). Paired table: closer to 0 = less mean bias.</li>
</ol>
</details>`;
}
function definitionsDetails() {
return `<details class="definitions">
<summary>Definitions</summary>
<ul>
<li><b>BtI</b>: fuse reflectance bands, then GCC.</li>
<li><b>ItB</b>: GCC on S2 and S3, then fuse GCC.</li>
<li><b>Scenario</b>: screening (<code>aggressive</code> / <code>nonaggressive</code>) × σ (20 / 30 days).</li>
<li><a href="phenology.html">Phenology</a> — PhenoCam SOS/EOS (TIMESAT).</li>
<li><b>R² vs mean</b> — coefficient of determination vs a constant mean(PhenoCam) baseline; JSON key <code>r_squared</code>; duplicates <code>nse_pc</code>. Not (Pearson <i>r</i>)².</li>
<li><code>metrics.json</code> — also Pearson <i>r</i>, RMSE, MAE, <code>n_samples</code>.</li>
</ul>
</details>`;
}
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 intro-short">
GCC at the 3×3 site window vs PhenoCam. Sections: PhenoCam → baselines → fusion (BtI, then ItB) → summaries.
<code>data/${siteName}/${season}/metrics.json</code>
</div>`;
html += howToReadBlock();
if (data.phenocam_stats) {
html += `<h2>PhenoCam (ground truth)</h2>`;
html += `<p class="section-note">Camera ROI GCC (not compared to itself). Dates / SOSEOS: <a href="phenology.html">Phenology</a>.</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 baselineTbl = baselineTable(data.baseline);
if (baselineTbl) {
html += `<h2>Baselines (vs PhenoCam)</h2>`;
html += `<p class="section-note">Same columns as fusion (vs PhenoCam). Higher R² vs mean / NSE_PC, lower nRMSE = better. S3 = coarse-only; Whittaker = smoothed S2-only.</p>`;
html += baselineTbl;
}
html += `<h2>Fusion (vs PhenoCam)</h2>`;
html += `<p class="section-note">BtI block vs ItB block: same row = same screening + σ. Within a block: four EFAST combinations.</p>`;
html += fusionTables(data.temporal || {});
html += derivedSection(data.derived);
html += definitionsDetails();
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>