265 lines
14 KiB
HTML
265 lines
14 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; }
|
||
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; }
|
||
.compare-note { font-size: 12px; color: #555; margin: 0 0 8px 0; max-width: 720px; }
|
||
.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>
|
||
</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>
|
||
const METRIC_COLS = ["pearson_r", "r_squared", "rmse", "mae", "nrmse", "nse_pc", "n_samples"];
|
||
function mv(m, c) {
|
||
return c === "nse_pc" ? (m.nse_pc ?? m.nse) : m[c];
|
||
}
|
||
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>${METRIC_COLS.map((c) => `<th>${c}</th>`).join("")}</tr>`;
|
||
const rows = keys.map((k) => {
|
||
const m = obj[k] || {};
|
||
return `<tr><td>${k}</td>${METRIC_COLS.map((c) => `<td class="num">${fmt(mv(m, c))}</td>`).join("")}</tr>`;
|
||
}).join("");
|
||
return `${heading}<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("");
|
||
const heading = title ? `<h2>${title}</h2>` : "";
|
||
const note = blurb ? `<p class="compare-note">${blurb}</p>` : "";
|
||
return `${heading}${note}<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) {
|
||
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>
|
||
</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: BtI vs ItB (paired)</h2>`;
|
||
html += `<p class="section-note">Per scenario (same strategy + σ), BtI and ItB side-by-side with <b>Δ = ItB − BtI</b>. Positive Δ is better for Pearson r, R², and NSE; negative Δ is better for RMSE, MAE, and NRMSE.</p>`;
|
||
html += btiItbCompareSection("", data.temporal, "") || `<p class="empty">No paired scenarios</p>`;
|
||
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>
|