Added gcc suitability stats.

This commit is contained in:
Felix Delattre 2026-06-17 12:29:35 +02:00
parent d55ee31e8d
commit a593683314
3 changed files with 907 additions and 15 deletions

View file

@ -181,6 +181,13 @@ body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f5f5f5; col
.stat-badge.bti { background: #e3f2fd; color: #0d47a1; }
.stat-badge.none { background: #f5f5f5; color: #777; font-weight: 400; }
.stat-badge.insuf { background: #fce4ec; color: #b71c1c; font-weight: 400; }
.stat-badge.pass { background: #e8f5e9; color: #1b5e20; }
.stat-badge.partial { background: #fff3e0; color: #e65100; font-weight: 400; }
.stat-badge.fail { background: #fce4ec; color: #b71c1c; font-weight: 400; }
.stat-site-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
.stat-site-table th, .stat-site-table td { padding: 4px 8px; text-align: left; border-bottom: 1px solid #f0f0f0; }
.stat-site-table th { color: #888; font-weight: 500; }
.stat-site-table .sval { font-variant-numeric: tabular-nums; }
.stat-row-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.stat-row-table td { padding: 3px 0; vertical-align: top; }
.stat-row-table .slabel { color: #888; width: 46%; }
@ -243,12 +250,7 @@ body { margin: 0; font: 13px/1.4 system-ui, sans-serif; background: #f5f5f5; col
<span class="overlay-meta" id="statsMeta"></span>
<button type="button" class="overlay-close" id="statsClose">Close</button>
</div>
<div id="statsTabGcc" class="stats-tab-panel" style="display:none">
<div class="stat-placeholder">
<p>GCC suitability</p>
Coming soon.
</div>
</div>
<div id="statsTabGcc" class="stats-tab-panel" style="display:none"></div>
<div id="statsTabComparison" class="stats-tab-panel"></div>
<div id="statsTabSites" class="stats-tab-panel" style="display:none">
<div class="stat-placeholder">
@ -352,6 +354,8 @@ let statsOverlayOpen = false;
let statsTab = "comparison";
let statsData = null;
let statsYear = null;
let gccSuitabilityData = null;
let gccSuitabilityYear = null;
const maps3 = {}; // { s2, fusion, s3 } Leaflet instances
const overlays3 = {}; // current ImageOverlay per map
const markers3 = {}; // site dot markers per map
@ -620,15 +624,23 @@ function betterOrderLabel(order) {
}
function updateStatsMeta() {
if (statsTab !== "comparison" || !statsData) {
qs("#statsMeta").textContent = `${currentYear}`;
if (statsTab === "comparison" && statsData) {
const nPairs = statsData.metrics?.nse?.n_pairs;
const alpha = statsData.alpha ?? 0.05;
qs("#statsMeta").textContent = nPairs != null
? `${nPairs} paired site${nPairs === 1 ? "" : "s"} · α=${alpha} · ${currentYear}`
: `${currentYear}`;
return;
}
const nPairs = statsData.metrics?.nse?.n_pairs;
const alpha = statsData.alpha ?? 0.05;
qs("#statsMeta").textContent = nPairs != null
? `${nPairs} paired site${nPairs === 1 ? "" : "s"} · α=${alpha} · ${currentYear}`
: `${currentYear}`;
if (statsTab === "gcc" && gccSuitabilityData) {
const nEl = gccSuitabilityData.n_sites_eligible;
const nRep = gccSuitabilityData.n_sites_repr_pass;
qs("#statsMeta").textContent = nEl != null
? `${nEl} eligible · ${nRep} representative · ${currentYear}`
: `${currentYear}`;
return;
}
qs("#statsMeta").textContent = `${currentYear}`;
}
function escHtml(s) {
@ -720,6 +732,130 @@ async function loadStatsPanel() {
}
}
function suitabilityVerdictLabel(v) {
if (v === "suitable") return "Suitable";
if (v === "partially suitable") return "Partially suitable";
if (v === "not suitable") return "Not suitable";
if (v === "insufficient data") return "Insufficient data";
return v || "—";
}
function suitabilityBadgeClass(v) {
if (v === "suitable") return "pass";
if (v === "partially suitable") return "partial";
if (v === "not suitable") return "fail";
return "insuf";
}
function yesNo(v) {
if (v === true) return "yes";
if (v === false) return "no";
return "—";
}
function winnerLabel(w) {
if (w === "bti") return "BtI";
if (w === "itb") return "ItB";
if (w === "tie") return "tie";
return "—";
}
function gccDroppedSitesList(data) {
const dropped = data.dropped_sites || [];
if (!dropped.length) return "";
const links = dropped.map(site =>
`<button type="button" class="stat-site-link" data-site="${escHtml(site)}">${escHtml(site)}</button>`
).join("");
return `<div class="stat-dropped">
<h4>Excluded sites (${dropped.length})</h4>
<p class="stat-dropped-note">Missing coordinates or fewer cloud-free S2 dates than the LOOCV minimum.</p>
<div class="stat-dropped-list">${links}</div>
</div>`;
}
function renderGccSuitabilityPanel(data) {
const panel = qs("#statsTabGcc");
const agg = data.aggregate || {};
const threshold = data.repr_r_threshold ?? 0.7;
const verdict = agg.suitability_verdict;
const badge = `<span class="stat-badge ${suitabilityBadgeClass(verdict)}">${suitabilityVerdictLabel(verdict)}</span>`;
const row = (label, val) =>
`<tr><td class="slabel">${label}</td><td class="sval">${val}</td></tr>`;
const siteRows = Object.entries(data.sites || {}).sort(([a], [b]) => a.localeCompare(b)).map(([site, s]) => {
const rep = s.representative ?? s.representativeness?.representative;
const repBadge = rep
? '<span class="stat-badge pass">pass</span>'
: '<span class="stat-badge fail">fail</span>';
const loocv = s.loocv || {};
return `<tr>
<td><button type="button" class="stat-site-link" data-site="${escHtml(site)}">${escHtml(site)}</button></td>
<td class="sval">${fmtStat(s.representativeness?.r)}</td>
<td>${repBadge}</td>
<td class="sval">${loocv.n_dates ?? "—"}</td>
<td class="sval">${winnerLabel(s.winner_s2)} / ${winnerLabel(s.winner_phenocam)}</td>
<td class="sval">${yesNo(s.winner_agreement)}</td>
</tr>`;
}).join("");
const aggregateCard = `<div class="stat-card">
<h3>Aggregate verdict ${badge}</h3>
<table class="stat-row-table">
${row("Pooled Spearman (errors)", fmtStat(agg.pooled_spearman))}
${row("Residual correlation", fmtStat(agg.residual_corr))}
${row("Winner agreement rate", fmtStat(agg.winner_agreement_rate))}
${row("LOOCV dates (pooled)", agg.n_loocv_dates ?? "—")}
${row("Representative sites", `${data.n_sites_repr_pass ?? "—"} / ${data.n_sites_total ?? "—"}`)}
${row("LOOCV-eligible sites", `${data.n_sites_eligible ?? "—"} / ${data.n_sites_total ?? "—"}`)}
</table>
</div>`;
const reprCard = `<div class="stat-card">
<h3>Line A — PhenoCam vs S2 representativeness</h3>
<p style="font-size:11px;color:#999;margin:0 0 8px">Pass when Pearson r ≥ ${threshold} (oblique footprint tracks co-located S2 GCC).</p>
<table class="stat-site-table">
<thead><tr>
<th>Site</th><th>r</th><th>Pass</th><th>LOOCV n</th><th>Winner S2 / PC</th><th>Agree</th>
</tr></thead>
<tbody>${siteRows || '<tr><td colspan="6">No sites</td></tr>'}</tbody>
</table>
</div>`;
const concordanceCard = `<div class="stat-card">
<h3>Line B — LOOCV concordance</h3>
<p style="font-size:11px;color:#999;margin:0 0 8px">Same EFAST predictions scored against held-out S2 truth vs PhenoCam. Winner agreement and error correlations test whether PhenoCam ranks fusion methods like the satellite-internal reference.</p>
<table class="stat-row-table">
${row("Min cloud-free S2 gate", data.min_cloudfree_s2 ?? "—")}
${row("Pooled LOOCV dates", agg.n_loocv_dates ?? "—")}
${row("Winner agreement rate", fmtStat(agg.winner_agreement_rate))}
</table>
</div>`;
panel.innerHTML =
`<div class="stat-summary">Is PhenoCam GCC a valid reference for ranking fusion accuracy? · ${data.n_sites_total ?? "—"} site(s) · r threshold ${threshold}</div>` +
`<div class="stat-grid">${aggregateCard}${reprCard}${concordanceCard}</div>` +
gccDroppedSitesList(data);
updateStatsMeta();
}
async function loadGccSuitabilityPanel() {
const panel = qs("#statsTabGcc");
panel.innerHTML = '<div class="stat-nodata">Loading…</div>';
try {
const data = await fetch(`data/gcc_suitability/${currentYear}.json`)
.then(r => { if (!r.ok) throw new Error(); return r.json(); });
gccSuitabilityData = data;
gccSuitabilityYear = currentYear;
renderGccSuitabilityPanel(data);
} catch {
gccSuitabilityData = null;
gccSuitabilityYear = null;
panel.innerHTML =
'<div class="stat-nodata">No GCC suitability file found — run 7-gcc-suitability.py first.</div>';
updateStatsMeta();
}
}
const STATS_TAB_PANELS = {
comparison: "#statsTabComparison",
gcc: "#statsTabGcc",
@ -736,6 +872,9 @@ function switchStatsTab(tab, updateHash = true) {
if (tab === "comparison") {
if (statsYear !== currentYear || !statsData) loadStatsPanel();
else updateStatsMeta();
} else if (tab === "gcc") {
if (gccSuitabilityYear !== currentYear || !gccSuitabilityData) loadGccSuitabilityPanel();
else updateStatsMeta();
} else {
updateStatsMeta();
}
@ -783,9 +922,12 @@ async function init() {
currentYear = +yearSel.value;
statsData = null;
statsYear = null;
gccSuitabilityData = null;
gccSuitabilityYear = null;
buildSiteList();
if (worldOverlayOpen) buildWorldMap();
if (statsOverlayOpen && statsTab === "comparison") loadStatsPanel();
if (statsOverlayOpen && statsTab === "gcc") loadGccSuitabilityPanel();
});
qs("#worldMapBtn").addEventListener("click", () => openWorldOverlay());
@ -797,6 +939,11 @@ async function init() {
if (!link) return;
pickSiteFromStats(link.dataset.site);
});
qs("#statsTabGcc").addEventListener("click", e => {
const link = e.target.closest(".stat-site-link");
if (!link) return;
pickSiteFromStats(link.dataset.site);
});
qs("#worldClose").addEventListener("click", () => closeWorldOverlay());
qs("#statsClose").addEventListener("click", () => closeStatsOverlay());
qs("#worldOverlay").addEventListener("click", e => {