@@ -1230,6 +1230,204 @@ def build_horizon_skill_curves_payload(
12301230 }
12311231
12321232
1233+ CONTINENT_REGION_ORDER : tuple [str , ...] = (
1234+ "Africa" ,
1235+ "Asia" ,
1236+ "Europe" ,
1237+ "North America" ,
1238+ "South America" ,
1239+ )
1240+
1241+
1242+ def _country_to_continent () -> dict [str , str ]:
1243+ from cybench .config import CONTINENT_DICT
1244+
1245+ out : dict [str , str ] = {}
1246+ for continent , codes in CONTINENT_DICT .items ():
1247+ for cc in codes :
1248+ out [str (cc ).upper ()] = continent
1249+ return out
1250+
1251+
1252+ def _continent_region_id (label : str ) -> str :
1253+ if label == "Global" :
1254+ return "global"
1255+ return label .lower ().replace (" " , "_" )
1256+
1257+
1258+ def _region_country_sets (work : pd .DataFrame ) -> list [tuple [str , str , set [str ]]]:
1259+ """(region_id, label, countries) for Global and each continent with data."""
1260+ if work .empty or "country" not in work .columns :
1261+ return [("global" , "Global" , set ())]
1262+ all_countries = {str (c ).upper () for c in work ["country" ].unique ()}
1263+ cc_to_cont = _country_to_continent ()
1264+ from cybench .config import CONTINENT_DICT
1265+
1266+ regions : list [tuple [str , str , set [str ]]] = [("global" , "Global" , all_countries )]
1267+ for continent in CONTINENT_REGION_ORDER :
1268+ codes = {str (c ).upper () for c in CONTINENT_DICT .get (continent , [])}
1269+ present = all_countries & codes
1270+ if present :
1271+ regions .append ((_continent_region_id (continent ), continent , present ))
1272+ return regions
1273+
1274+
1275+ def _filter_work_to_countries (work : pd .DataFrame , countries : set [str ]) -> pd .DataFrame :
1276+ if work .empty or not countries :
1277+ return work .iloc [0 :0 ].copy ()
1278+ upper = {str (c ).upper () for c in countries }
1279+ return work [work ["country" ].astype (str ).str .upper ().isin (upper )].copy ()
1280+
1281+
1282+ def build_continent_horizon_payload (
1283+ df : pd .DataFrame ,
1284+ * ,
1285+ representatives : dict [str , str ] | None = None ,
1286+ ) -> dict [str , Any ]:
1287+ """Per-region horizon medians for EOS family representatives."""
1288+ from cybench .runs .analysis .model_family_radar_lib import (
1289+ FAMILY_COLORS ,
1290+ FAMILY_ORDER ,
1291+ MODEL_DISPLAY_NAMES ,
1292+ pick_representatives ,
1293+ )
1294+
1295+ horizons = horizons_in_data (df )
1296+ if len (horizons ) < 2 :
1297+ return {
1298+ "horizons" : [],
1299+ "by_crop" : {},
1300+ "note" : "Need at least two forecast horizons with collected summaries." ,
1301+ }
1302+
1303+ rep_source_hz = "eos" if "eos" in horizons else horizons [- 1 ]
1304+ rep_work = _filter_summary_work (df , batch_horizon = rep_source_hz )
1305+ reps = pick_representatives (rep_work , overrides = representatives )
1306+ trend_model = reps .get ("Naive baselines" , "trend" )
1307+
1308+ value_columns = tuple (c for c in HORIZON_SKILL_VALUE_COLUMNS if c in df .columns )
1309+ crop_keys = ["all" , * _crop_keys (df )]
1310+ by_crop : dict [str , Any ] = {}
1311+
1312+ for crop_key in crop_keys :
1313+ crop_filter = None if crop_key == "all" else crop_key
1314+ crop_work = _filter_summary_work (df , crop = crop_filter )
1315+ region_specs = _region_country_sets (crop_work )
1316+ regions_meta : list [dict [str , Any ]] = []
1317+ entries : list [dict [str , Any ]] = []
1318+
1319+ for region_id , region_label , countries in region_specs :
1320+ regional_work = _filter_work_to_countries (crop_work , countries )
1321+ regions_meta .append (
1322+ {
1323+ "id" : region_id ,
1324+ "label" : region_label ,
1325+ "n_countries" : len (countries ),
1326+ }
1327+ )
1328+ if regional_work .empty :
1329+ continue
1330+ for family in FAMILY_ORDER :
1331+ model = reps .get (family )
1332+ if not model or model == trend_model :
1333+ continue
1334+ entry = _build_model_horizon_entry_independent (
1335+ regional_work ,
1336+ model = model ,
1337+ trend_model = trend_model ,
1338+ horizons = horizons ,
1339+ value_columns = value_columns ,
1340+ )
1341+ if entry is None :
1342+ continue
1343+ entries .append (
1344+ {
1345+ "region" : region_label ,
1346+ "region_id" : region_id ,
1347+ "family" : family ,
1348+ "display" : MODEL_DISPLAY_NAMES .get (model , model ),
1349+ "color" : FAMILY_COLORS .get (family , "#666" ),
1350+ ** entry ,
1351+ }
1352+ )
1353+
1354+ by_crop [crop_key ] = {
1355+ "regions" : regions_meta ,
1356+ "entries" : entries ,
1357+ }
1358+
1359+ rep_labels = ", " .join (f"{ f } : { m } " for f , m in reps .items ())
1360+ return {
1361+ "horizons" : [
1362+ {"id" : hz , "label" : HORIZON_DISPLAY_LABELS .get (hz , hz ), "order" : i }
1363+ for i , hz in enumerate (horizons )
1364+ ],
1365+ "representatives_summary" : rep_labels ,
1366+ "by_crop" : by_crop ,
1367+ "note" : (
1368+ "EOS family representatives only. Median across countries within each region "
1369+ "(each country weighs equally). Horizons scored independently — blank cells mean "
1370+ "no runs at that horizon in that region. Small regions (e.g. Asia, Americas) may "
1371+ "have few countries; check n in tooltips via country counts per horizon in the "
1372+ "exported table."
1373+ ),
1374+ }
1375+
1376+
1377+ def build_continent_horizon_table (
1378+ df : pd .DataFrame ,
1379+ * ,
1380+ crop : str | None = None ,
1381+ horizons : tuple [str , ...] | None = None ,
1382+ metrics : tuple [str , ...] = ("nrmse" , "r2" , "r_spatial" , "r_temporal" , "r_res" ),
1383+ representatives : dict [str , str ] | None = None ,
1384+ ) -> pd .DataFrame :
1385+ """Flatten :func:`build_continent_horizon_payload` to a CSV-friendly table."""
1386+ payload = build_continent_horizon_payload (df , representatives = representatives )
1387+ horizons = horizons or tuple (h ["id" ] for h in payload .get ("horizons" , []))
1388+ crop_key = crop or "all"
1389+ slice_data = payload .get ("by_crop" , {}).get (crop_key , {})
1390+ present_metrics = tuple (m for m in metrics if m in HORIZON_SKILL_VALUE_COLUMNS )
1391+
1392+ rows : list [dict [str , Any ]] = []
1393+ for entry in slice_data .get ("entries" , []):
1394+ row : dict [str , Any ] = {
1395+ "region" : entry .get ("region" ),
1396+ "region_id" : entry .get ("region_id" ),
1397+ "family" : entry .get ("family" ),
1398+ "model" : entry .get ("model" ),
1399+ "display_name" : entry .get ("display" ),
1400+ "eos_only" : entry .get ("eos_only" , False ),
1401+ }
1402+ points_by_hz = {p ["horizon" ]: p for p in entry .get ("points" , [])}
1403+ for hz in horizons :
1404+ pt = points_by_hz .get (hz )
1405+ row [f"n_countries_{ hz } " ] = pt .get ("n_countries" ) if pt else None
1406+ for metric in present_metrics :
1407+ stats = (pt .get ("metrics" ) or {}).get (metric ) if pt else None
1408+ if isinstance (stats , dict ):
1409+ row [f"{ hz } _{ metric } _median" ] = stats .get ("median" )
1410+ row [f"{ hz } _{ metric } _q25" ] = stats .get ("q25" )
1411+ row [f"{ hz } _{ metric } _q75" ] = stats .get ("q75" )
1412+ else :
1413+ row [f"{ hz } _{ metric } _median" ] = float ("nan" )
1414+ row [f"{ hz } _{ metric } _q25" ] = float ("nan" )
1415+ row [f"{ hz } _{ metric } _q75" ] = float ("nan" )
1416+ rows .append (row )
1417+
1418+ if not rows :
1419+ return pd .DataFrame ()
1420+ region_order = ["Global" , * list (CONTINENT_REGION_ORDER )]
1421+ ord_map = {name : i for i , name in enumerate (region_order )}
1422+ out = pd .DataFrame (rows )
1423+ out ["_region_ord" ] = out ["region" ].map (lambda r : ord_map .get (r , 99 ))
1424+ return (
1425+ out .sort_values (["_region_ord" , "family" , "model" ])
1426+ .drop (columns = ["_region_ord" ])
1427+ .reset_index (drop = True )
1428+ )
1429+
1430+
12331431def _model_country_count (work : pd .DataFrame , model : str ) -> int :
12341432 sub = work [work ["model" ] == model ]
12351433 if sub .empty or "country" not in sub .columns :
@@ -1523,6 +1721,7 @@ def build_insights_payload(output_root: Path, *, version: int = 2) -> dict[str,
15231721 "model_country" : model_country ,
15241722 "model_country_skilled" : model_country_skilled ,
15251723 "horizon_skill_curves" : build_horizon_skill_curves_payload (df ),
1724+ "continent_horizons" : build_continent_horizon_payload (df ),
15261725 "crop_comparison" : build_crop_comparison_payload (df ),
15271726 "country_map_cc" : country_map_cc ,
15281727 "benchmark_map_isos" : benchmark_map_isos ,
0 commit comments