@@ -416,6 +416,86 @@ def build_results_dataframe(
416416 return df
417417
418418
419+ def summarize_expanded_tasks (df : pd .DataFrame ) -> pd .DataFrame :
420+ """Summarize expanded task results so each task_id has exactly one row.
421+
422+ Expanded tasks (from MDX wildcards) share the same task_id, which causes
423+ "last row wins" when loaded into the TM1 cube. This function aggregates
424+ duplicate task_id rows into a single summary row per task_id.
425+
426+ Non-expanded tasks (single row per task_id) pass through unchanged.
427+
428+ :param df: DataFrame with task results (may have duplicate task_ids)
429+ :return: DataFrame with one row per task_id
430+ """
431+ if df .empty :
432+ return df
433+
434+ # Check if any task_id appears more than once
435+ task_id_counts = df ["task_id" ].value_counts ()
436+ if (task_id_counts <= 1 ).all ():
437+ return df
438+
439+ summary_rows = []
440+ for task_id , group in df .groupby ("task_id" , sort = False ):
441+ if len (group ) == 1 :
442+ summary_rows .append (group .iloc [0 ].to_dict ())
443+ continue
444+
445+ # Start with first row as base (shared fields: instance, process, predecessors, stage, etc.)
446+ summary = group .iloc [0 ].to_dict ()
447+ count = len (group )
448+
449+ # Status: summarize success/fail counts
450+ success_count = (group ["status" ] == "Success" ).sum ()
451+ fail_count = count - success_count
452+ if fail_count == 0 :
453+ summary ["status" ] = "Success"
454+ elif success_count == 0 :
455+ summary ["status" ] = "Fail"
456+ else :
457+ summary ["status" ] = f"Partial ({ success_count } /{ count } Success)"
458+
459+ # Time: earliest start, latest end
460+ summary ["start_time" ] = group ["start_time" ].min ()
461+ summary ["end_time" ] = group ["end_time" ].max ()
462+
463+ # Duration: wall clock from earliest start to latest end
464+ try :
465+ start = pd .to_datetime (summary ["start_time" ])
466+ end = pd .to_datetime (summary ["end_time" ])
467+ summary ["duration_seconds" ] = round ((end - start ).total_seconds (), 3 )
468+ except (ValueError , TypeError ):
469+ summary ["duration_seconds" ] = group ["duration_seconds" ].sum ()
470+
471+ # Parameters: collect all individual parameter sets
472+ param_list = []
473+ for params_str in group ["parameters" ]:
474+ try :
475+ param_list .append (json .loads (params_str ))
476+ except (json .JSONDecodeError , TypeError ):
477+ param_list .append (params_str )
478+ summary ["parameters" ] = json .dumps ({"expanded" : count , "parameters" : param_list })
479+
480+ # Error messages: concatenate non-empty errors
481+ errors = [e for e in group ["error_message" ] if e ]
482+ summary ["error_message" ] = "; " .join (errors )
483+
484+ # Retries: sum across all expanded tasks
485+ summary ["retry_count" ] = int (group ["retry_count" ].sum ())
486+ summary ["retries" ] = summary ["retry_count" ]
487+
488+ summary_rows .append (summary )
489+
490+ result = pd .DataFrame (summary_rows )
491+ summarized_count = (task_id_counts > 1 ).sum ()
492+ logger .info (
493+ f"Summarized { summarized_count } expanded task(s) for TM1 upload "
494+ f"({ len (df )} rows -> { len (result )} rows)"
495+ )
496+ return result
497+
498+
419499def export_results_to_csv (
420500 stats_db : StatsDatabase ,
421501 workflow : str ,
0 commit comments