Skip to content

Commit 7be9e4a

Browse files
committed
Generate automatic report for poject assessment
1 parent 35d4577 commit 7be9e4a

4 files changed

Lines changed: 365 additions & 4 deletions

File tree

epm/generate_report.gms

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ Parameters
172172
pYearlyDiscountedWeightedCostsZone(z, *, y) 'Discounted weighted annual cost [million USD] by zone and year'
173173
pCostsZone(z, *) 'Total cost [million USD] by zone and cost category'
174174
pYearlyCostsCountry(c, *, y) 'Annual cost summary [million USD] by country and year'
175-
pYearlyCostsSystem
175+
pYearlyCostsSystem(sumhdr, y)
176176
pCostsSystem(*) 'System-level cost summary [million USD], weighted and discounted'
177177
pCostsSystemPerMWh(*) 'System-level cost summary [$ / MWh], weighted and discounted'
178178
pYearlySystemCostEnergyBasis(y) 'System cost energy denominator [MWh] by year'

epm/postprocessing/assessment.py

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
402755
def make_assessment_capacity_diff(
403756
epm_results, dict_specs, folder, scenario_pairs
404757
):

0 commit comments

Comments
 (0)