Skip to content

Commit 45231b5

Browse files
feat: added scatter summary plot per horizon for all indicators (#753)
* feat: added scatter summary plot per horizon for all indicators * refactored main * fix: b2a societal cost add, with colours * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * release notes * fix: add option for not collection years * fix: remove b2a non-central, add stats values box * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: wildcard was only working if scenarios are enabled --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent ab294c3 commit 45231b5

3 files changed

Lines changed: 208 additions & 2 deletions

File tree

doc/release_notes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131

3232
* Change dispatch of biomass and biogas generators in CBA by (a) setting the buses' marginal prices as the generators' marginal costs and (b) removing energy budget constraints ([#719](https://github.com/open-energy-transition/open-tyndp/pull/719)).
3333

34+
* Add CBA per horizon summary plots for each indicator benchmarking TYNDP and Open-TYNDP ([#753](https://github.com/open-energy-transition/open-tyndp/pull/753)).
35+
3436
**Bugfixes and Compatibility**
3537

3638
* Rename bus for `t339` project (Tyrrhenian) from ITSI to ITVI ([#751](https://github.com/open-energy-transition/open-tyndp/pull/751)).

rules/cba.smk

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,36 @@ rule summarize_indicators_per_project:
556556
"../scripts/cba/summarize_indicators.py"
557557

558558

559+
def summary_benchmark_indicators(w):
560+
"""
561+
Returns Indicator CSVs as inputs for the per-horizon summary benchmark plot.
562+
If collection scenarios, returns the weighted-average ensemble indicators CSV as inputs for plotting.
563+
"""
564+
run = get_run_name(w)
565+
if run in cba_collection_scenarios(w):
566+
return expand(
567+
rules.average_indicators_per_project_and_planning_horizon.output.indicators,
568+
planning_horizons=[w.planning_horizons],
569+
cba_project=cba_projects(w),
570+
run=[run],
571+
)
572+
return expand(
573+
rules.collect_indicators.output.indicators,
574+
planning_horizons=[w.planning_horizons],
575+
run=[run],
576+
)
577+
578+
579+
rule plot_summary_projects_benchmark:
580+
input:
581+
indicators=summary_benchmark_indicators,
582+
output:
583+
plot_file=RESULTS
584+
+ "cba/ensemble_plots/summary_benchmark_{planning_horizons}.png",
585+
script:
586+
"../scripts/cba/plot_benchmark_indicators.py"
587+
588+
559589
rule summarize_all_indicators:
560590
input:
561591
indicators=lambda w: expand(
@@ -691,6 +721,13 @@ def collect_cba_scenario_inputs(w):
691721
run=cba_scenarios(w),
692722
)
693723
)
724+
inputs.extend(
725+
expand(
726+
rules.plot_summary_projects_benchmark.output.plot_file,
727+
planning_horizons=config_provider("cba", "planning_horizons")(w),
728+
run=cba_scenarios(w),
729+
)
730+
)
694731

695732
run = get_run_name(w)
696733
if run in cba_collection_scenarios(w):
@@ -743,6 +780,13 @@ def cba_ensemble_inputs(w):
743780
run=runs,
744781
)
745782
)
783+
inputs.extend(
784+
expand(
785+
rules.plot_summary_projects_benchmark.output.plot_file,
786+
planning_horizons=config["cba"]["planning_horizons"],
787+
run=runs,
788+
)
789+
)
746790
return inputs
747791

748792

scripts/cba/plot_benchmark_indicators.py

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,152 @@ def plot_project_benchmarks(
352352
plt.close(fig)
353353

354354

355+
def plot_summary_projects_benchmark(
356+
df: pd.DataFrame,
357+
output_path: Path,
358+
planning_horizon: str | None = None,
359+
area_subtitle: str | None = None,
360+
eps: float = 1e-6,
361+
) -> None:
362+
"""Plot one summary subplot per indicator for each horizon"""
363+
model_df = df[df["source"] == "Open-TYNDP"]
364+
if model_df.empty:
365+
logger.info("No Open-TYNDP data to plot")
366+
return
367+
weighted_df = model_df[model_df["cyear"] == "weighted-average"]
368+
if not weighted_df.empty:
369+
model_df = weighted_df
370+
371+
average_df = pd.concat(
372+
[model_df, df[df["source"] == "TYNDP 2024"]], ignore_index=True
373+
)
374+
375+
indicators = sorted(
376+
average_df.loc[average_df["source"] == "Open-TYNDP", "indicator"]
377+
.dropna()
378+
.unique()
379+
)
380+
project_ids = sorted(
381+
average_df.loc[average_df["source"] == "Open-TYNDP", "project_id"]
382+
.dropna()
383+
.unique()
384+
)
385+
386+
plot_items = {}
387+
for indicator in indicators:
388+
pairs = []
389+
for project_id in project_ids:
390+
project_df = average_df[average_df["project_id"] == project_id]
391+
if indicator == "B2a_societal_cost_variation":
392+
model_val = select_value_by_subindex(
393+
project_df, indicator, "Open-TYNDP", "central"
394+
)
395+
bench_val = select_value_by_subindex(
396+
project_df, indicator, "TYNDP 2024", "central"
397+
)
398+
if model_val is not None and bench_val is not None:
399+
pairs.append((bench_val, model_val))
400+
else:
401+
model = benchmark_range(project_df, indicator, source="Open-TYNDP")
402+
bench = benchmark_range(project_df, indicator, source="TYNDP 2024")
403+
if model is not None and bench is not None:
404+
pairs.append((bench[1], model[1]))
405+
if pairs:
406+
plot_items[indicator] = pairs
407+
if not plot_items:
408+
logger.info("Incomplete benchmark data to plot")
409+
return
410+
411+
ncols = min(4, len(plot_items))
412+
nrows = (len(plot_items) + ncols - 1) // ncols
413+
fig, axes = plt.subplots(
414+
nrows=nrows,
415+
ncols=ncols,
416+
figsize=(3.6 * ncols, 3.3 * nrows),
417+
squeeze=False,
418+
)
419+
420+
for ax, (indicator, pairs) in zip(axes.flatten(), plot_items.items()):
421+
xs = [p[0] for p in pairs]
422+
ys = [p[1] for p in pairs]
423+
colors = "tab:blue"
424+
ax.scatter(
425+
xs,
426+
ys,
427+
s=20,
428+
color=colors,
429+
alpha=0.6,
430+
edgecolor="white",
431+
linewidth=0.5,
432+
)
433+
ax.axline(
434+
(0, 0), slope=1, color="black", linestyle="--", linewidth=1, alpha=0.5
435+
)
436+
437+
combined = [abs(v) for v in (*xs, *ys) if v != 0]
438+
if combined:
439+
abs_max = max(combined)
440+
abs_min = min(combined)
441+
if abs_min > 0 and abs_max / abs_min >= 1e3:
442+
linthresh = max(abs_min, 1.0)
443+
ax.set_xscale("symlog", linthresh=linthresh)
444+
ax.set_yscale("symlog", linthresh=linthresh)
445+
446+
units = average_df.loc[
447+
(average_df["indicator"] == indicator)
448+
& (average_df["source"] == "Open-TYNDP"),
449+
"units",
450+
].dropna()
451+
unit_label = units.iloc[0] if not units.empty else ""
452+
ax.set_title(
453+
f"{indicator} ({unit_label})" if unit_label else indicator, fontsize=9
454+
)
455+
ax.set_xlabel("TYNDP 2024")
456+
ax.set_ylabel("Open-TYNDP")
457+
ax.axhline(0, color="gray", linewidth=0.5, alpha=0.4)
458+
ax.axvline(0, color="gray", linewidth=0.5, alpha=0.4)
459+
ax.grid(alpha=0.3)
460+
461+
benchmark_df = pd.DataFrame(pairs, columns=["TYNDP 2024", "Open-TYNDP"])
462+
errors = (
463+
(benchmark_df["Open-TYNDP"] - benchmark_df["TYNDP 2024"]).abs()
464+
/ (
465+
(benchmark_df["Open-TYNDP"].abs() + benchmark_df["TYNDP 2024"].abs())
466+
/ 2
467+
+ eps
468+
)
469+
* 100
470+
)
471+
values = (
472+
f"n = {len(pairs)}\n"
473+
f"sMAPE = {errors.mean():.1f}%\n"
474+
f"sMdAPE = {errors.median():.1f}%"
475+
)
476+
ax.text(
477+
0.05,
478+
0.95,
479+
values,
480+
transform=ax.transAxes,
481+
fontsize=6,
482+
verticalalignment="top",
483+
bbox=dict(boxstyle="round", facecolor="white", alpha=0.8),
484+
)
485+
486+
for ax in axes.flatten()[len(plot_items) :]:
487+
ax.axis("off")
488+
489+
title = "Benchmark of indicators across all projects"
490+
if planning_horizon:
491+
title += f" ({planning_horizon})"
492+
fig.suptitle(title, y=0.995)
493+
if area_subtitle:
494+
fig.text(0.5, 0.965, area_subtitle, ha="center", va="center", fontsize=9)
495+
496+
fig.tight_layout(rect=[0, 0, 1, 0.94])
497+
fig.savefig(output_path, dpi=400)
498+
plt.close(fig)
499+
500+
355501
def create_plots(
356502
indicators_file: str | Path,
357503
output_path: str | Path,
@@ -435,6 +581,20 @@ def create_plots(
435581
set_scenario_config(snakemake)
436582

437583
planning_horizon = snakemake.wildcards.get("planning_horizons")
438-
output_target = snakemake.output.get("plot_file") or snakemake.output.plot_dir
439584
area = snakemake.config.get("cba", {}).get("area")
440-
create_plots(snakemake.input.indicators, output_target, planning_horizon, area)
585+
586+
if "cba_project" in snakemake.wildcards.keys():
587+
output_target = snakemake.output.get("plot_file") or snakemake.output.plot_dir
588+
create_plots(snakemake.input.indicators, output_target, planning_horizon, area)
589+
elif not snakemake.input.indicators:
590+
logger.warning(
591+
"No indicators input files for summary plot: %s", snakemake.output.plot_file
592+
)
593+
else:
594+
df = pd.concat(map(pd.read_csv, snakemake.input.indicators), ignore_index=True)
595+
plot_summary_projects_benchmark(
596+
df,
597+
Path(snakemake.output.plot_file),
598+
planning_horizon,
599+
format_area_subtitle(area),
600+
)

0 commit comments

Comments
 (0)