@@ -920,6 +920,72 @@ def _eos_only_family_points(
920920 return [{"horizon" : "eos" , "metrics" : metrics , "n_countries" : n_countries }]
921921
922922
923+ def _build_model_horizon_entry (
924+ * ,
925+ model : str ,
926+ wide : pd .DataFrame ,
927+ work : pd .DataFrame ,
928+ trend_model : str ,
929+ horizons : tuple [str , ...],
930+ value_columns : tuple [str , ...],
931+ ) -> dict [str , Any ] | None :
932+ eos_only = model in EOS_ONLY_HORIZON_CURVE_MODELS
933+ if eos_only :
934+ points = _eos_only_family_points (
935+ work ,
936+ model = model ,
937+ trend_model = trend_model ,
938+ value_columns = value_columns ,
939+ )
940+ else :
941+ points = _family_curve_points (
942+ wide ,
943+ model = model ,
944+ trend_model = trend_model ,
945+ horizons = horizons ,
946+ value_columns = value_columns ,
947+ )
948+ if not points :
949+ return None
950+ n_horizons_with_data = sum (
951+ 1 for p in points if (p .get ("metrics" ) or {}).get ("nrmse" , {}).get ("median" ) is not None
952+ )
953+ return {
954+ "model" : model ,
955+ "points" : points ,
956+ "eos_only" : eos_only ,
957+ "plot" : (not eos_only ) and n_horizons_with_data >= 2 ,
958+ }
959+
960+
961+ def _build_all_model_horizon_entries (
962+ work : pd .DataFrame ,
963+ wide : pd .DataFrame ,
964+ * ,
965+ trend_model : str ,
966+ horizons : tuple [str , ...],
967+ value_columns : tuple [str , ...],
968+ model_display_names : dict [str , str ],
969+ ) -> list [dict [str , Any ]]:
970+ if work .empty or "model" not in work .columns :
971+ return []
972+ entries : list [dict [str , Any ]] = []
973+ for model in sorted (work ["model" ].astype (str ).unique ()):
974+ entry = _build_model_horizon_entry (
975+ model = model ,
976+ wide = wide ,
977+ work = work ,
978+ trend_model = trend_model ,
979+ horizons = horizons ,
980+ value_columns = value_columns ,
981+ )
982+ if entry is None :
983+ continue
984+ entry ["display" ] = model_display_names .get (model , model )
985+ entries .append (entry )
986+ return entries
987+
988+
923989def build_horizon_skill_curves_payload (
924990 df : pd .DataFrame ,
925991 * ,
@@ -953,64 +1019,56 @@ def build_horizon_skill_curves_payload(
9531019 by_crop : dict [str , Any ] = {}
9541020 for crop_key in crop_keys :
9551021 crop_filter = None if crop_key == "all" else crop_key
956- work = _filter_summary_work (df , crop = crop_filter )
957- work = work [work ["model" ].isin (rep_models )]
958- wide = _wide_country_model_horizon_metrics (
959- work , horizons , crop = crop_filter , value_columns = value_columns
960- )
961- if wide .empty :
962- by_crop [crop_key ] = {
963- "n_countries" : 0 ,
964- "countries" : [],
965- "excluded_countries" : [],
966- "families" : [],
967- }
968- continue
969-
970- countries = sorted (wide ["country" ].astype (str ).unique ())
9711022 crop_work = _filter_summary_work (df , crop = crop_filter )
1023+ wide_all = _wide_country_model_horizon_metrics (
1024+ crop_work , horizons , crop = crop_filter , value_columns = value_columns
1025+ )
1026+ countries = (
1027+ sorted (wide_all ["country" ].astype (str ).unique ()) if not wide_all .empty else []
1028+ )
9721029 any_countries = (
9731030 set (crop_work ["country" ].astype (str ).unique ()) if "country" in crop_work .columns else set ()
9741031 )
9751032 excluded = sorted (any_countries - set (countries ))
9761033
1034+ models = _build_all_model_horizon_entries (
1035+ crop_work ,
1036+ wide_all ,
1037+ trend_model = trend_model ,
1038+ horizons = horizons ,
1039+ value_columns = value_columns ,
1040+ model_display_names = MODEL_DISPLAY_NAMES ,
1041+ )
1042+
1043+ work = crop_work [crop_work ["model" ].isin (rep_models )]
1044+ wide = (
1045+ wide_all [wide_all ["model" ].isin (rep_models )].copy ()
1046+ if not wide_all .empty
1047+ else wide_all
1048+ )
1049+
9771050 families : list [dict [str , Any ]] = []
9781051 for family in FAMILY_ORDER :
9791052 model = reps .get (family )
9801053 if not model or model == trend_model :
9811054 continue
982- eos_only = model in EOS_ONLY_HORIZON_CURVE_MODELS
983- if eos_only :
984- points = _eos_only_family_points (
985- work ,
986- model = model ,
987- trend_model = trend_model ,
988- value_columns = value_columns ,
989- )
990- else :
991- points = _family_curve_points (
992- wide ,
993- model = model ,
994- trend_model = trend_model ,
995- horizons = horizons ,
996- value_columns = value_columns ,
997- )
998- if not points :
999- continue
1000- n_horizons_with_data = sum (
1001- 1
1002- for p in points
1003- if (p .get ("metrics" ) or {}).get ("nrmse" , {}).get ("median" ) is not None
1055+ entry = _build_model_horizon_entry (
1056+ model = model ,
1057+ wide = wide ,
1058+ work = work ,
1059+ trend_model = trend_model ,
1060+ horizons = horizons ,
1061+ value_columns = value_columns ,
10041062 )
1063+ if entry is None :
1064+ continue
10051065 families .append (
10061066 {
10071067 "family" : family ,
10081068 "model" : model ,
10091069 "display" : MODEL_DISPLAY_NAMES .get (model , model ),
10101070 "color" : FAMILY_COLORS .get (family , "#666" ),
1011- "points" : points ,
1012- "eos_only" : eos_only ,
1013- "plot" : (not eos_only ) and n_horizons_with_data >= 2 ,
1071+ ** entry ,
10141072 }
10151073 )
10161074
@@ -1019,6 +1077,7 @@ def build_horizon_skill_curves_payload(
10191077 "countries" : countries ,
10201078 "excluded_countries" : excluded ,
10211079 "families" : families ,
1080+ "models" : models ,
10221081 }
10231082
10241083 rep_labels = ", " .join (f"{ f } : { m } " for f , m in reps .items ())
@@ -1043,9 +1102,14 @@ def build_horizon_skill_curves_payload(
10431102 "Hyperparameters are tuned per horizon (screening at each lead time)."
10441103 ),
10451104 "plot_excluded_note" : (
1046- "EOS-only baselines (LPJmL) are omitted from the curve plot; see the table for "
1105+ "EOS-only baselines (LPJmL) are omitted from the curve plot; see the tables for "
10471106 "their end-of-season median."
10481107 ),
1108+ "models_table_note" : (
1109+ "Median per metric across countries. Multi-horizon models use only countries with "
1110+ "data at every collected horizon (inner join). EOS-only baselines show end-of-season "
1111+ "values only."
1112+ ),
10491113 "axes" : _horizon_skill_axes (),
10501114 "metric_notes" : {
10511115 "nrmse" : "Median pooled NRMSE across countries (lower is better)." ,
@@ -1213,7 +1277,6 @@ def build_insights_payload(output_root: Path, *, version: int = 1) -> dict[str,
12131277 """Build JSON-serializable payload for the global insights dashboard."""
12141278 paths = discover_summary_tables (output_root , version = version )
12151279 df = load_summary_frame (paths )
1216- horizon_detail , horizon_summary = compare_horizons (df )
12171280
12181281 available_horizons = horizons_in_data (df )
12191282 leaderboards : dict [str , dict [str , list [dict [str , Any ]]]] = {}
@@ -1250,30 +1313,11 @@ def build_insights_payload(output_root: Path, *, version: int = 1) -> dict[str,
12501313 "leaderboards_skilled" : leaderboards_skilled ,
12511314 "model_country" : model_country ,
12521315 "model_country_skilled" : model_country_skilled ,
1253- "horizon_summary" : _df_records (horizon_summary ),
1254- "horizon_detail" : _df_records (horizon_detail ),
1255- "overall_horizon" : _overall_horizon_stats (horizon_detail ),
12561316 "horizon_skill_curves" : build_horizon_skill_curves_payload (df ),
12571317 "crop_comparison" : build_crop_comparison_payload (df ),
12581318 }
12591319
12601320
1261- def _overall_horizon_stats (detail : pd .DataFrame ) -> dict [str , Any ]:
1262- if detail .empty :
1263- return {}
1264- weights = detail ["pair_weight" ]
1265- return {
1266- "n_pairs" : int (len (detail )),
1267- "eos_win_rate" : float (detail ["eos_better" ].mean ()),
1268- "mean_delta_nrmse" : float (detail ["delta_nrmse" ].mean ()),
1269- "weighted_delta_nrmse" : float (_weighted_mean (detail ["delta_nrmse" ], weights )),
1270- "interpretation" : (
1271- "delta_nrmse = mid − eos; positive values mean end-of-season (nowcast) "
1272- "has lower NRMSE than mid-season."
1273- ),
1274- }
1275-
1276-
12771321def _overall_crop_pair_stats (
12781322 detail : pd .DataFrame , crop_a : str , crop_b : str
12791323) -> dict [str , Any ]:
0 commit comments