367 lines
18 KiB
HTML
367 lines
18 KiB
HTML
<!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 <table>…</table> 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 / SOS–EOS: <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>
|