@@ -399,6 +399,359 @@ def make_assessment_npv_comparison(
399399 )
400400
401401
402+ def make_assessment_cost_template_csv (
403+ epm_results ,
404+ folder ,
405+ scenario_pairs ,
406+ dict_specs = None ,
407+ trade_attrs = None ,
408+ reserve_attrs = None ,
409+ ):
410+ """
411+ Export a single cost assessment CSV in wide format for investment analysis.
412+
413+ Output structure (single file per project):
414+ ```
415+ EPM System Cost Comparison: Project vs Baseline (values in million USD)
416+ BASELINE = without project | PROJECT = with project | DIFFERENCE = PROJECT - BASELINE
417+ Positive difference = project INCREASES system cost | Negative = project REDUCES system cost
418+
419+ Scenario | Cost Category (M$) | 2025 | 2030 | ... | NPV
420+ -----------|------------------------|------|------|-----|------
421+ BASELINE | Investment costs | 100 | 120 | ... | 450
422+ BASELINE | Fixed O&M costs | 25 | 28 | ... | 100
423+ BASELINE | Variable O&M costs | 15 | 18 | ... | 60
424+ BASELINE | Fuel costs | 80 | 85 | ... | 320
425+ BASELINE | Trade costs | 10 | 12 | ... | 40
426+ BASELINE | Unmet demand costs | 0 | 0 | ... | 0
427+ BASELINE | Unmet reserve costs | 5 | 3 | ... | 15
428+ BASELINE | TOTAL | 235 | 266 | ... | 985
429+ | | | | |
430+ PROJECT | ... | | | |
431+ ...
432+ DIFFERENCE | ... | | | |
433+ ```
434+
435+ Also generates a stacked bar chart showing cost differences by component.
436+ """
437+ if "baseline" not in scenario_pairs :
438+ return
439+
440+ if "pYearlyCostsSystem" not in epm_results :
441+ log_warning ("pYearlyCostsSystem not found in results; skipping assessment CSV export." )
442+ return
443+
444+ # Define cost category order and mapping for cleaner names
445+ cost_category_order = [
446+ "Investment costs" ,
447+ "Fixed O&M costs" ,
448+ "Variable O&M costs" ,
449+ "Fuel costs" ,
450+ "Trade costs" ,
451+ "Unmet demand costs" ,
452+ "Unmet reserve costs" ,
453+ ]
454+
455+ # Mapping from raw GAMS attribute names to clean category names
456+ # Raw names from generate_report.gms sumhdr set
457+ category_mapping = {
458+ # Investment
459+ "Investment costs: $m" : "Investment costs" ,
460+ # O&M (note: GAMS uses "Fixed O&M" not "Fixed O&M costs")
461+ "Fixed O&M: $m" : "Fixed O&M costs" ,
462+ "Variable O&M: $m" : "Variable O&M costs" ,
463+ # Other operational
464+ "Startup costs: $m" : "Variable O&M costs" , # Merge startup into variable O&M
465+ "Fuel costs: $m" : "Fuel costs" ,
466+ "Spinning reserve costs: $m" : "Fixed O&M costs" , # Merge into fixed O&M
467+ "Transmission costs: $m" : "Fixed O&M costs" , # Merge into fixed O&M
468+ # Trade
469+ "Import costs with external zones: $m" : "Trade costs" ,
470+ "Export revenues with external zones: $m" : "Trade costs" ,
471+ "Import costs with internal zones: $m" : "Trade costs" ,
472+ "Export revenues with internal zones: $m" : "Trade costs" ,
473+ "Trade costs: $m" : "Trade costs" , # If already aggregated
474+ # Unmet demand
475+ "Unmet demand costs: $m" : "Unmet demand costs" ,
476+ "Excess generation: $m" : "Unmet demand costs" ,
477+ "VRE curtailment: $m" : "Unmet demand costs" ,
478+ # Unmet reserves (will be aggregated by _simplify_attributes if reserve_attrs provided)
479+ "Unmet country spinning reserve costs: $m" : "Unmet reserve costs" ,
480+ "Unmet country planning reserve costs: $m" : "Unmet reserve costs" ,
481+ "Unmet country CO2 backstop cost: $m" : "Unmet reserve costs" ,
482+ "Unmet system planning reserve costs: $m" : "Unmet reserve costs" ,
483+ "Unmet system spinning reserve costs: $m" : "Unmet reserve costs" ,
484+ "Unmet system CO2 backstop cost: $m" : "Unmet reserve costs" ,
485+ "Unmet reserve costs: $m" : "Unmet reserve costs" , # If already aggregated
486+ }
487+
488+ df_yearly = epm_results ["pYearlyCostsSystem" ].copy ()
489+
490+ if reserve_attrs :
491+ df_yearly = _simplify_attributes (df_yearly , "Unmet reserve costs: $m" , reserve_attrs )
492+ if trade_attrs :
493+ df_yearly = _simplify_attributes (df_yearly , "Trade costs: $m" , trade_attrs )
494+
495+ df_yearly = df_yearly .loc [df_yearly ["attribute" ] != "NPV of system cost: $m" ]
496+
497+ # Apply category mapping
498+ df_yearly ["attribute" ] = df_yearly ["attribute" ].map (
499+ lambda x : category_mapping .get (x , x )
500+ )
501+
502+ # Remove any remaining ": $m" suffix for unmapped attributes
503+ df_yearly ["attribute" ] = df_yearly ["attribute" ].str .replace (": $m" , "" , regex = False )
504+
505+ # Aggregate by category (mapping may have grouped multiple raw attrs into one)
506+ df_yearly = df_yearly .groupby (
507+ [c for c in df_yearly .columns if c != "value" ],
508+ as_index = False ,
509+ observed = False ,
510+ )["value" ].sum ()
511+
512+ # Get NPV data for the final column
513+ df_npv_all = None
514+ if "pCostsSystem" in epm_results :
515+ df_npv_all = epm_results ["pCostsSystem" ].copy ()
516+ df_npv_all = df_npv_all .loc [df_npv_all ["attribute" ] != "NPV of system cost: $m" ]
517+ if reserve_attrs :
518+ df_npv_all = _simplify_attributes (df_npv_all , "Unmet reserve costs: $m" , reserve_attrs )
519+ if trade_attrs :
520+ df_npv_all = _simplify_attributes (df_npv_all , "Trade costs: $m" , trade_attrs )
521+ # Apply same category mapping
522+ df_npv_all ["attribute" ] = df_npv_all ["attribute" ].map (
523+ lambda x : category_mapping .get (x , x )
524+ )
525+ # Remove any remaining ": $m" suffix
526+ df_npv_all ["attribute" ] = df_npv_all ["attribute" ].str .replace (": $m" , "" , regex = False )
527+ # Aggregate by category
528+ df_npv_all = df_npv_all .groupby (
529+ [c for c in df_npv_all .columns if c != "value" ],
530+ as_index = False ,
531+ observed = False ,
532+ )["value" ].sum ()
533+
534+ for scenario_cf in scenario_pairs ["baseline" ]:
535+ df_base = df_yearly [df_yearly ["scenario" ] == "baseline" ].copy ()
536+ df_cf = df_yearly [df_yearly ["scenario" ] == scenario_cf ].copy ()
537+
538+ if df_base .empty or df_cf .empty :
539+ continue
540+
541+ project_name = scenario_cf .split ("@" )[1 ] if "@" in scenario_cf else scenario_cf
542+
543+ # Pivot to wide format: rows=attribute, columns=year
544+ df_base_wide = df_base .pivot_table (
545+ index = "attribute" ,
546+ columns = "year" ,
547+ values = "value" ,
548+ aggfunc = "sum" ,
549+ fill_value = 0 ,
550+ )
551+ df_cf_wide = df_cf .pivot_table (
552+ index = "attribute" ,
553+ columns = "year" ,
554+ values = "value" ,
555+ aggfunc = "sum" ,
556+ fill_value = 0 ,
557+ )
558+
559+ # Ensure both have the same columns (years)
560+ all_years = sorted (set (df_base_wide .columns ) | set (df_cf_wide .columns ))
561+ for yr in all_years :
562+ if yr not in df_base_wide .columns :
563+ df_base_wide [yr ] = 0
564+ if yr not in df_cf_wide .columns :
565+ df_cf_wide [yr ] = 0
566+ df_base_wide = df_base_wide [all_years ]
567+ df_cf_wide = df_cf_wide [all_years ]
568+
569+ # Reindex to standard category order (only include categories present in data)
570+ all_categories = [c for c in cost_category_order if c in df_base_wide .index or c in df_cf_wide .index ]
571+
572+ df_base_wide = df_base_wide .reindex (all_categories , fill_value = 0 )
573+ df_cf_wide = df_cf_wide .reindex (all_categories , fill_value = 0 )
574+
575+ # Compute difference (project - baseline)
576+ df_diff_wide = df_cf_wide - df_base_wide
577+
578+ # Add NPV column if available
579+ if df_npv_all is not None :
580+ npv_base = df_npv_all [df_npv_all ["scenario" ] == "baseline" ].copy ()
581+ npv_cf = df_npv_all [df_npv_all ["scenario" ] == scenario_cf ].copy ()
582+
583+ npv_base_dict = npv_base .groupby ("attribute" )["value" ].sum ().to_dict ()
584+ npv_cf_dict = npv_cf .groupby ("attribute" )["value" ].sum ().to_dict ()
585+
586+ df_base_wide ["NPV" ] = df_base_wide .index .map (lambda x : npv_base_dict .get (x , 0 ))
587+ df_cf_wide ["NPV" ] = df_cf_wide .index .map (lambda x : npv_cf_dict .get (x , 0 ))
588+ df_diff_wide ["NPV" ] = df_cf_wide ["NPV" ] - df_base_wide ["NPV" ]
589+
590+ # Add total row to each section
591+ df_base_wide .loc ["TOTAL" ] = df_base_wide .sum ()
592+ df_cf_wide .loc ["TOTAL" ] = df_cf_wide .sum ()
593+ df_diff_wide .loc ["TOTAL" ] = df_diff_wide .sum ()
594+
595+ # Add scenario labels and reset index
596+ df_base_wide = df_base_wide .reset_index ().rename (columns = {"attribute" : "Cost Category (M$)" })
597+ df_cf_wide = df_cf_wide .reset_index ().rename (columns = {"attribute" : "Cost Category (M$)" })
598+ df_diff_wide = df_diff_wide .reset_index ().rename (columns = {"attribute" : "Cost Category (M$)" })
599+
600+ df_base_wide .insert (0 , "Scenario" , "BASELINE" )
601+ df_cf_wide .insert (0 , "Scenario" , "PROJECT" )
602+ df_diff_wide .insert (0 , "Scenario" , "DIFFERENCE" )
603+
604+ # Create empty separator row
605+ cols = df_base_wide .columns .tolist ()
606+ separator = pd .DataFrame ([["" ] * len (cols )], columns = cols )
607+
608+ # Combine data sections
609+ df_combined = pd .concat (
610+ [
611+ df_base_wide ,
612+ separator ,
613+ df_cf_wide ,
614+ separator ,
615+ df_diff_wide ,
616+ ],
617+ ignore_index = True ,
618+ )
619+
620+ # Build metadata header lines (will be written before the CSV data)
621+ metadata_lines = [
622+ f"# EPM System Cost Comparison: { project_name } vs Baseline" ,
623+ "# Values in million USD (M$)" ,
624+ "#" ,
625+ "# Scenarios:" ,
626+ "# BASELINE = System costs WITHOUT the project" ,
627+ "# PROJECT = System costs WITH the project" ,
628+ "# DIFFERENCE = PROJECT - BASELINE" ,
629+ "#" ,
630+ "# Interpretation:" ,
631+ "# Positive difference = project INCREASES system cost" ,
632+ "# Negative difference = project REDUCES system cost (savings)" ,
633+ "#" ,
634+ ]
635+
636+ # Save CSV with metadata header
637+ filename = os .path .join (folder , f"AssessmentCostTemplate_{ project_name } .csv" )
638+ with open (filename , "w" , encoding = "utf-8" ) as f :
639+ # Write metadata as comment lines
640+ for line in metadata_lines :
641+ f .write (line + "\n " )
642+ # Write data table
643+ df_combined .to_csv (f , index = False )
644+
645+ # Generate stacked bar chart for cost differences
646+ if dict_specs is not None :
647+ _make_cost_diff_stacked_bar (
648+ df_diff_wide ,
649+ dict_specs ,
650+ folder ,
651+ project_name ,
652+ )
653+
654+
655+ def _make_cost_diff_stacked_bar (df_diff , dict_specs , folder , project_name ):
656+ """
657+ Generate a stacked bar chart showing cost differences by component over years.
658+
659+ Positive bars (above zero) = project increases costs
660+ Negative bars (below zero) = project reduces costs (savings)
661+ """
662+ # Exclude TOTAL row
663+ df_plot = df_diff [df_diff ["Cost Category (M$)" ] != "TOTAL" ].copy ()
664+
665+ if df_plot .empty :
666+ return
667+
668+ # Get unique categories (avoid duplicates)
669+ categories = df_plot ["Cost Category (M$)" ].unique ().tolist ()
670+ year_cols = [c for c in df_plot .columns if c not in ["Scenario" , "Cost Category (M$)" , "NPV" ]]
671+
672+ if not year_cols :
673+ return
674+
675+ # Create figure
676+ _ , ax = plt .subplots (figsize = (max (8 , len (year_cols ) * 1.2 ), 6 ))
677+
678+ x = np .arange (len (year_cols ))
679+ width = 0.6
680+
681+ # Get colors from dict_specs or use defaults
682+ colors = dict_specs .get ("colors" , {})
683+ default_colors = plt .cm .Set2 (np .linspace (0 , 1 , len (categories )))
684+
685+ # Separate positive and negative values for proper stacking
686+ bottom_pos = np .zeros (len (year_cols ))
687+ bottom_neg = np .zeros (len (year_cols ))
688+
689+ # Track which categories have been added to legend
690+ legend_added = set ()
691+
692+ for i , category in enumerate (categories ):
693+ row = df_plot [df_plot ["Cost Category (M$)" ] == category ]
694+ if row .empty :
695+ continue
696+ values = row [year_cols ].values .flatten ()
697+ color = colors .get (category , default_colors [i ])
698+
699+ # Split into positive and negative
700+ pos_values = np .where (values > 0 , values , 0 )
701+ neg_values = np .where (values < 0 , values , 0 )
702+
703+ # Add label only once per category
704+ label = category if category not in legend_added else None
705+
706+ if np .any (pos_values != 0 ):
707+ ax .bar (x , pos_values , width , bottom = bottom_pos , label = label , color = color )
708+ bottom_pos += pos_values
709+ if label :
710+ legend_added .add (category )
711+ label = None # Don't add again for negative
712+
713+ if np .any (neg_values != 0 ):
714+ ax .bar (x , neg_values , width , bottom = bottom_neg , label = label , color = color )
715+ bottom_neg += neg_values
716+ if label :
717+ legend_added .add (category )
718+
719+ # Add zero line
720+ ax .axhline (y = 0 , color = "black" , linewidth = 0.8 )
721+
722+ # Add total annotation on each bar
723+ totals = df_diff [df_diff ["Cost Category (M$)" ] == "TOTAL" ][year_cols ].values .flatten ()
724+ for i , total in enumerate (totals ):
725+ y_pos = total if total >= 0 else total
726+ va = "bottom" if total >= 0 else "top"
727+ offset = 5 if total >= 0 else - 5
728+ ax .annotate (
729+ f"{ total :+,.0f} " ,
730+ xy = (i , y_pos ),
731+ ha = "center" ,
732+ va = va ,
733+ fontsize = 9 ,
734+ fontweight = "bold" ,
735+ xytext = (0 , offset ),
736+ textcoords = "offset points" ,
737+ )
738+
739+ ax .set_xlabel ("Year" )
740+ ax .set_ylabel ("Cost Difference (M$)" )
741+ ax .set_title (f"System Cost Difference: { project_name } vs Baseline\n (Positive = higher cost with project)" )
742+ ax .set_xticks (x )
743+ ax .set_xticklabels (year_cols , rotation = 45 if len (year_cols ) > 6 else 0 )
744+ ax .legend (loc = "upper left" , bbox_to_anchor = (1.02 , 1 ), fontsize = 9 )
745+
746+ # Format y-axis
747+ ax .yaxis .set_major_formatter (plt .FuncFormatter (lambda x , p : f"{ x :,.0f} " ))
748+
749+ plt .tight_layout ()
750+ filename = os .path .join (folder , f"AssessmentCostDiffStacked_{ project_name } .pdf" )
751+ plt .savefig (filename , bbox_inches = "tight" )
752+ plt .close ()
753+
754+
402755def make_assessment_capacity_diff (
403756 epm_results , dict_specs , folder , scenario_pairs
404757):
0 commit comments