Skip to content

Commit 1cb6e30

Browse files
dynamic maps
1 parent 132f3bd commit 1cb6e30

6 files changed

Lines changed: 763 additions & 4 deletions

File tree

cybench/runs/analysis/collect_walk_forward_results.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@
5555
build_html,
5656
bundle_referenced_assets,
5757
)
58+
from cybench.runs.viz.region_map_lib import (
59+
build_region_map_payload,
60+
bundle_region_map_assets,
61+
strip_map_pngs_from_records,
62+
write_region_map_sidecar,
63+
)
5864

5965

6066
def _model_column_from_hydra(run_dir: Path) -> str | None:
@@ -446,15 +452,26 @@ def write_model_comparison_dashboard(
446452
records = summary_rows_to_dashboard_records(summary_rows, output_dir)
447453
if not records:
448454
raise ValueError("No summary rows available for dashboard.")
455+
map_payload = build_region_map_payload(output_dir, summary_rows)
449456
html_dir = str(output_dir)
450457
if bundle_assets:
458+
if map_payload.get("datasets"):
459+
map_payload = bundle_region_map_assets(
460+
map_payload, output_dir, assets_dirname="assets"
461+
)
462+
if map_payload.get("geojson_by_country"):
463+
write_region_map_sidecar(output_dir, map_payload)
464+
if map_payload.get("datasets") and map_payload.get("geojson_by_country"):
465+
records = strip_map_pngs_from_records(records)
451466
records = bundle_referenced_assets(
452467
records=records,
453468
output_dir=html_dir,
454469
assets_dirname="assets",
455470
)
471+
elif map_payload.get("datasets") and map_payload.get("geojson_by_country"):
472+
records = strip_map_pngs_from_records(records)
456473
html_path = output_dir / "compare_models.html"
457-
html_path.write_text(build_html(records), encoding="utf-8")
474+
html_path.write_text(build_html(records, map_payload=map_payload), encoding="utf-8")
458475
return html_path
459476

460477

cybench/runs/viz/build_results_dashboard.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,11 +401,14 @@ def load_records(sources: List[SourceConfig], output_dir: str) -> List[Dict]:
401401
return records
402402

403403

404-
def build_html(records: List[Dict]) -> str:
404+
def build_html(records: List[Dict], map_payload: dict | None = None) -> str:
405405
data_json = json.dumps(records)
406+
map_json = json.dumps(map_payload or {})
406407
template_path = Path(__file__).with_name("dashboard_template.html")
407408
template = template_path.read_text(encoding="utf-8")
408-
return template.replace("__DATA_JSON__", data_json)
409+
return (
410+
template.replace("__DATA_JSON__", data_json).replace("__MAP_DATA_JSON__", map_json)
411+
)
409412

410413

411414
def bundle_referenced_assets(

cybench/runs/viz/dashboard_template.html

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<head>
44
<meta charset="utf-8" />
55
<title>CY-Bench Multi-Model Dashboard</title>
6+
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
67
<style>
78
:root {
89
--bg: #f6f8fa;
@@ -186,6 +187,31 @@
186187
min-height: 120px;
187188
background: #fff;
188189
}
190+
.region-map-wrap { width: 100%; overflow: hidden; min-height: 280px; }
191+
.region-map-wrap svg { display: block; width: 100%; height: auto; }
192+
.region-map-wrap .region {
193+
stroke: #fff;
194+
stroke-width: 0.6px;
195+
transition: fill 0.12s;
196+
}
197+
.region-map-legend {
198+
display: flex;
199+
flex-wrap: wrap;
200+
align-items: center;
201+
gap: 0.5rem 0.75rem;
202+
margin-top: 0.35rem;
203+
font-size: 0.78rem;
204+
color: var(--muted);
205+
justify-content: center;
206+
}
207+
.region-map-gradient {
208+
display: inline-block;
209+
width: 140px;
210+
height: 10px;
211+
border-radius: 3px;
212+
border: 1px solid var(--border);
213+
background: linear-gradient(to right, #ffffcc, #fd8d3c, #800026);
214+
}
189215
</style>
190216
</head>
191217
<body>
@@ -230,6 +256,18 @@ <h3>Drill-down plots</h3>
230256
<button data-panel="temporal">Temporal</button>
231257
</div>
232258
<div id="missing-note" class="muted"></div>
259+
<div id="map-grid-dynamic" class="img-grid two" style="display:none;">
260+
<div>
261+
<div id="map-caption-left-dyn" class="map-caption muted">Ground truth (mean yield)</div>
262+
<div id="region-map-actual" class="region-map-wrap"></div>
263+
<div class="region-map-legend"><span class="region-map-gradient" aria-hidden="true"></span><span id="region-map-legend-actual"></span></div>
264+
</div>
265+
<div>
266+
<div id="map-caption-right-dyn" class="map-caption muted">Model prediction</div>
267+
<div id="region-map-pred" class="region-map-wrap"></div>
268+
<div class="region-map-legend"><span class="region-map-gradient" aria-hidden="true"></span><span id="region-map-legend-pred"></span></div>
269+
</div>
270+
</div>
233271
<div id="img-grid" class="img-grid two">
234272
<div>
235273
<div id="map-caption-left" class="map-caption muted">Ground truth (mean yield)</div>
@@ -245,6 +283,7 @@ <h3>Drill-down plots</h3>
245283

246284
<script>
247285
const DATA = __DATA_JSON__;
286+
const MAP_DATA = __MAP_DATA_JSON__;
248287
const coverageInfo = document.getElementById("coverage-info");
249288
const heatmapWrap = document.getElementById("heatmap-wrap");
250289
const datasetFilter = document.getElementById("dataset-filter");
@@ -259,8 +298,134 @@ <h3>Drill-down plots</h3>
259298
const mapCaptionRight = document.getElementById("map-caption-right");
260299
const drillImageA = document.getElementById("drill-image-a");
261300
const drillImageB = document.getElementById("drill-image-b");
301+
const mapGridDynamic = document.getElementById("map-grid-dynamic");
302+
const regionMapActual = document.getElementById("region-map-actual");
303+
const regionMapPred = document.getElementById("region-map-pred");
304+
const regionMapLegendActual = document.getElementById("region-map-legend-actual");
305+
const regionMapLegendPred = document.getElementById("region-map-legend-pred");
262306
const panelButtons = Array.from(document.querySelectorAll(".toolbar button"));
263307

308+
const HAS_DYNAMIC_MAPS = Boolean(
309+
MAP_DATA && MAP_DATA.datasets && Object.keys(MAP_DATA.datasets).length
310+
&& MAP_DATA.geojson_by_country && Object.keys(MAP_DATA.geojson_by_country).length
311+
);
312+
const geoCache = new Map();
313+
const REGION_MAP_WIDTH = 520;
314+
const REGION_MAP_HEIGHT = 420;
315+
const REGION_MAP_MARGIN = 10;
316+
const REGION_FILL_MISSING = "#f0f0f0";
317+
318+
function fmtMap(v, digits = 2) {
319+
if (v === null || v === undefined || Number.isNaN(v)) return "—";
320+
return Number(v).toFixed(digits);
321+
}
322+
323+
async function loadCountryGeojson(country) {
324+
if (geoCache.has(country)) return geoCache.get(country);
325+
const href = (MAP_DATA.geojson_by_country || {})[country];
326+
if (!href) return null;
327+
const geo = await d3.json(href);
328+
const features = (geo.features || []).map(f => ({
329+
...f,
330+
properties: { ...f.properties, loc: String(f.properties.loc ?? "") },
331+
}));
332+
geoCache.set(country, features);
333+
return features;
334+
}
335+
336+
function yieldColor(value, extent) {
337+
if (value == null || !extent) return REGION_FILL_MISSING;
338+
const [lo, hi] = extent;
339+
if (hi <= lo) return d3.interpolateOrRd(0.5);
340+
const t = Math.max(0, Math.min(1, (value - lo) / (hi - lo)));
341+
return d3.interpolateOrRd(t);
342+
}
343+
344+
function sharedYieldExtent(actualMap, predMap) {
345+
const vals = [
346+
...Object.values(actualMap || {}),
347+
...Object.values(predMap || {}),
348+
].filter(v => v != null && !Number.isNaN(v));
349+
if (!vals.length) return null;
350+
const lo = Math.min(...vals);
351+
const hi = Math.max(...vals);
352+
const pad = (hi - lo) * 0.05 || 0.5;
353+
return [lo - pad, hi + pad];
354+
}
355+
356+
function renderRegionChoropleth(container, features, valueByLoc, extent, title) {
357+
container.innerHTML = "";
358+
if (!features || !features.length) {
359+
container.innerHTML = `<p class="muted">${title}: no geometry.</p>`;
360+
return;
361+
}
362+
const svg = d3.select(container).append("svg")
363+
.attr("viewBox", `0 0 ${REGION_MAP_WIDTH} ${REGION_MAP_HEIGHT}`)
364+
.attr("role", "img")
365+
.attr("aria-label", title);
366+
const projection = d3.geoMercator().fitExtent(
367+
[[REGION_MAP_MARGIN, REGION_MAP_MARGIN],
368+
[REGION_MAP_WIDTH - REGION_MAP_MARGIN, REGION_MAP_HEIGHT - REGION_MAP_MARGIN]],
369+
{ type: "FeatureCollection", features },
370+
);
371+
const path = d3.geoPath(projection);
372+
svg.selectAll("path.region")
373+
.data(features)
374+
.join("path")
375+
.attr("class", "region")
376+
.attr("d", path)
377+
.attr("fill", d => {
378+
const loc = d.properties.loc;
379+
const v = valueByLoc ? valueByLoc[loc] : null;
380+
return yieldColor(v, extent);
381+
})
382+
.attr("stroke", "#fff")
383+
.selectAll("title")
384+
.data(d => [d])
385+
.join("title")
386+
.text(d => {
387+
const loc = d.properties.loc;
388+
const v = valueByLoc ? valueByLoc[loc] : null;
389+
return v == null ? `${loc}: no data` : `${loc}: ${fmtMap(v)}`;
390+
});
391+
}
392+
393+
async function renderDynamicMaps(dataset, model) {
394+
const slice = (MAP_DATA.datasets || {})[dataset];
395+
if (!slice) return false;
396+
const country = slice.country;
397+
const features = await loadCountryGeojson(country);
398+
if (!features) return false;
399+
const actual = slice.actual || {};
400+
const pred = (slice.models || {})[model] || {};
401+
const extent = sharedYieldExtent(actual, pred);
402+
renderRegionChoropleth(
403+
regionMapActual,
404+
features,
405+
actual,
406+
extent,
407+
"Ground truth (mean yield)",
408+
);
409+
renderRegionChoropleth(
410+
regionMapPred,
411+
features,
412+
pred,
413+
extent,
414+
`Model prediction (${model})`,
415+
);
416+
if (extent) {
417+
const label = `${fmtMap(extent[0])}${fmtMap(extent[1])}`;
418+
regionMapLegendActual.textContent = label;
419+
regionMapLegendPred.textContent = label;
420+
}
421+
return true;
422+
}
423+
424+
function showMapMode(useDynamic) {
425+
mapGridDynamic.style.display = useDynamic ? "" : "none";
426+
imgGrid.style.display = useDynamic ? "none" : "";
427+
}
428+
264429
const METRIC_COLUMNS = [
265430
{ key: "region_year_r2", label: "R²", group: "Region-Year", higherBetter: true, title: "R² on all region-year rows (absolute prediction quality)" },
266431
{ key: "region_year_nrmse", label: "NRMSE", group: "Region-Year", higherBetter: false, title: "Normalized RMSE on absolute yields" },
@@ -652,6 +817,23 @@ <h3>Drill-down plots</h3>
652817
missingNote.textContent = "";
653818

654819
if (activePanel === "maps") {
820+
if (HAS_DYNAMIC_MAPS && (MAP_DATA.datasets || {})[selectedCell.dataset]) {
821+
showMapMode(true);
822+
renderDynamicMaps(selectedCell.dataset, selectedCell.model).then(ok => {
823+
if (!ok) {
824+
showMapMode(false);
825+
missingNote.textContent =
826+
"Could not render dynamic maps (missing geometry or preds). "
827+
+ "Re-run collect to export preds/ and ensure polygons exist.";
828+
}
829+
}).catch(err => {
830+
showMapMode(false);
831+
missingNote.textContent = `Map error: ${err}`;
832+
});
833+
return;
834+
}
835+
836+
showMapMode(false);
655837
imgGrid.classList.add("two");
656838
drillColB.style.display = "";
657839
mapCaptionLeft.style.display = "";
@@ -665,14 +847,15 @@ <h3>Drill-down plots</h3>
665847
drillImageA.removeAttribute("src");
666848
drillImageB.removeAttribute("src");
667849
missingNote.textContent =
668-
"No map images found. Re-run collect with plot enabled.";
850+
"No map images found. Re-run collect with --plot, or ensure preds/ exists for dynamic maps.";
669851
return;
670852
}
671853
if (actual) drillImageA.src = actual; else drillImageA.removeAttribute("src");
672854
if (pred) drillImageB.src = pred; else drillImageB.removeAttribute("src");
673855
return;
674856
}
675857

858+
showMapMode(false);
676859
imgGrid.classList.remove("two");
677860
drillColB.style.display = "none";
678861
mapCaptionLeft.style.display = "none";

0 commit comments

Comments
 (0)