|
| 1 | +import pandas as pd |
| 2 | +from matplotlib import pyplot as plt |
| 3 | +from matplotlib.pyplot import setp |
| 4 | +from matplotlib.ticker import PercentFormatter |
| 5 | + |
| 6 | +from switch_model.tools.graph.main import graph_scenarios, graph, Scenario |
| 7 | + |
| 8 | +X_LABEL = "WECC-wide Storage Capacity (TWh)" |
| 9 | + |
| 10 | + |
| 11 | +def set_styles(tools): |
| 12 | + tools.plt.rcParams['font.family'] = 'sans-serif' |
| 13 | + |
| 14 | + |
| 15 | +@graph( |
| 16 | + "figure_1", |
| 17 | + supports_multi_scenario=True, |
| 18 | + title="Figure 1: The value of storage in the WECC", |
| 19 | + note="Panel 1: Shaded area represents the 25th to 75th percentile range" |
| 20 | + " for dual values.\nDual values for non-binding energy balance constraints" |
| 21 | + " are ignored." |
| 22 | +) |
| 23 | +def figure_1(tools): |
| 24 | + set_styles(tools) |
| 25 | + |
| 26 | + figure_size = (12, 12) |
| 27 | + fig = tools.get_figure(size=figure_size) |
| 28 | + axes = fig.subplots(2, 2) |
| 29 | + |
| 30 | + tools.plt.rcParams['lines.marker'] = '.' |
| 31 | + figure_1_panel_1(tools, axes[0][0]) |
| 32 | + figure_1_panel_2(tools, axes[0][1]) |
| 33 | + figure_1_panel_3(tools, axes[1][0]) |
| 34 | + figure_1_panel_4(tools, axes[1][1]) |
| 35 | + tools.plt.rcParams['lines.marker'] = None |
| 36 | + |
| 37 | + for row in axes: |
| 38 | + for ax in row: |
| 39 | + ax.set_xlabel(X_LABEL) |
| 40 | + |
| 41 | + |
| 42 | +def figure_1_panel_1(tools, ax): |
| 43 | + df = tools.get_dataframe('load_balance.csv') \ |
| 44 | + .rename({"normalized_energy_balance_duals_dollar_per_mwh": "value"}, axis=1) |
| 45 | + df = df[df.value != 0] |
| 46 | + load_balance_group = df \ |
| 47 | + .groupby("scenario_name") \ |
| 48 | + .value |
| 49 | + mean = load_balance_group.mean().rename("mean_val") |
| 50 | + upper = load_balance_group.quantile(0.75).rename("upper") |
| 51 | + lower = load_balance_group.quantile(0.25).rename("lower") |
| 52 | + df = pd.concat([lower, mean, upper], axis=1) |
| 53 | + # Convert from $/MWh to cents/kWh |
| 54 | + df *= 0.1 |
| 55 | + |
| 56 | + x = df.index.values |
| 57 | + ax.plot(x, df.mean_val.values, color="black") |
| 58 | + ax.fill_between(x, df.lower.values, df.upper.values, alpha=0.5, color="gray") |
| 59 | + ax.set_ylabel("Mean Energy Balance Duals (cents/kWh)") |
| 60 | + |
| 61 | + |
| 62 | +def figure_1_panel_2(tools, ax): |
| 63 | + # Read dispatch.csv |
| 64 | + df = tools.get_dataframe( |
| 65 | + 'dispatch.csv', |
| 66 | + usecols=["gen_tech", "gen_energy_source", "Curtailment_MW", "is_renewable", "tp_weight_in_year_hrs"], |
| 67 | + na_filter=False, # For performance |
| 68 | + ) |
| 69 | + # Keep only renewable |
| 70 | + df = df[df["is_renewable"]] |
| 71 | + # Add the gen_type column |
| 72 | + df = tools.transform.gen_type(df) |
| 73 | + # Convert to GW |
| 74 | + df["value"] = df["Curtailment_MW"] * df["tp_weight_in_year_hrs"] / 1000 |
| 75 | + df = df.groupby(["scenario_name", "gen_type"], as_index=False).value.sum() |
| 76 | + df = df.pivot(index="scenario_name", columns="gen_type", values="value") |
| 77 | + df /= 1000 |
| 78 | + df = df.rename_axis("Technology", axis=1) |
| 79 | + df.plot( |
| 80 | + ax=ax, |
| 81 | + color=tools.get_colors(), |
| 82 | + ) |
| 83 | + ax.set_ylabel("Yearly Curtailment (GWh)") |
| 84 | + |
| 85 | + |
| 86 | +def figure_1_panel_3(tools, ax): |
| 87 | + df = tools.get_dataframe("transmission.csv", usecols=["BuildTx"], convert_dot_to_na=True) |
| 88 | + df = df.fillna(0).rename({"BuildTx": "value"}, axis=1) |
| 89 | + df = df.groupby("scenario_name").value.sum() / 1000 |
| 90 | + df.plot(ax=ax, color="black") |
| 91 | + ax.set_ylim(0, 60) |
| 92 | + ax.set_ylabel("New Transmission Capacity Built (GW)") |
| 93 | + |
| 94 | + |
| 95 | +def figure_1_panel_4(tools, ax): |
| 96 | + df = tools.get_dataframe("BuildGen.csv").rename(columns={ |
| 97 | + "GEN_BLD_YRS_1": "GENERATION_PROJECT", |
| 98 | + "GEN_BLD_YRS_2": "period", |
| 99 | + "BuildGen": "value" |
| 100 | + }) |
| 101 | + df = df[df.period == 2050] |
| 102 | + projects = tools.get_dataframe("generation_projects_info.csv", from_inputs=True, usecols=[ |
| 103 | + "GENERATION_PROJECT", "gen_tech", "gen_energy_source" |
| 104 | + ]).drop("scenario_index", axis=1) |
| 105 | + df = df.merge( |
| 106 | + projects, |
| 107 | + on=["GENERATION_PROJECT", "scenario_name"], |
| 108 | + validate="one_to_one", |
| 109 | + how="left" |
| 110 | + ).drop("GENERATION_PROJECT", axis=1) |
| 111 | + del projects |
| 112 | + df = tools.transform.gen_type(df) |
| 113 | + df = df.rename({"GenCapacity": "value"}, axis=1) |
| 114 | + df = df.groupby(["scenario_name", "gen_type"], as_index=False).value.sum() |
| 115 | + df.value /= 1000 |
| 116 | + df = df[df["gen_type"] != "Storage"] |
| 117 | + df = df[df.value != 0] |
| 118 | + df = df.pivot(index="scenario_name", columns="gen_type", values="value") |
| 119 | + df.loc["total", :] = df.sum() |
| 120 | + df = df.sort_values("total", axis=1) |
| 121 | + df = df.drop("total") |
| 122 | + df = df.rename_axis("Technology", axis=1) |
| 123 | + df.plot( |
| 124 | + ax=ax, |
| 125 | + kind="area", |
| 126 | + color=tools.get_colors(), |
| 127 | + ) |
| 128 | + ax.set_ylabel("Newly Capacity Installations (GW)") |
| 129 | + |
| 130 | + |
| 131 | +@graph( |
| 132 | + "figure_2", |
| 133 | + title="Figure 2: Generation Mix", |
| 134 | + supports_multi_scenario=True |
| 135 | +) |
| 136 | +def figure_2(tools): |
| 137 | + set_styles(tools) |
| 138 | + fig = tools.get_figure(size=(12, 12)) |
| 139 | + ax1 = fig.add_subplot(3, 2, 1) |
| 140 | + ax2 = fig.add_subplot(3, 2, 2, sharey=ax1) |
| 141 | + ax3 = fig.add_subplot(3, 2, 3, sharey=ax1, sharex=ax1) |
| 142 | + ax4 = fig.add_subplot(3, 2, 4, sharey=ax1, sharex=ax2) |
| 143 | + ax5 = fig.add_subplot(3, 2, (5, 6)) |
| 144 | + |
| 145 | + setp(ax2.get_yticklabels(), visible=False) |
| 146 | + setp(ax4.get_yticklabels(), visible=False) |
| 147 | + setp(ax1.get_xticklabels(), visible=False) |
| 148 | + setp(ax2.get_xticklabels(), visible=False) |
| 149 | + |
| 150 | + figure_2_energy_balance(tools, ax1, scenario_name=1.94) |
| 151 | + figure_2_energy_balance(tools, ax2, scenario_name=4) |
| 152 | + figure_2_energy_balance(tools, ax3, scenario_name=16) |
| 153 | + figure_2_energy_balance(tools, ax4, scenario_name=64) |
| 154 | + figure_2_panel_5(tools, ax5) |
| 155 | + handles, labels = ax1.get_legend_handles_labels() |
| 156 | + unique = [(h, l) for i, (h, l) in enumerate(zip(handles, labels)) if l not in labels[:i]] |
| 157 | + plt.figlegend(*zip(*unique)) |
| 158 | + |
| 159 | + |
| 160 | +def figure_2_panel_5(tools, ax): |
| 161 | + df = tools.get_dataframe("gen_cap.csv", |
| 162 | + usecols=["gen_tech", "gen_energy_source", "GenCapacity"]) |
| 163 | + df = tools.transform.gen_type(df) |
| 164 | + df = df.rename({"GenCapacity": "value"}, axis=1) |
| 165 | + df = df.groupby(["scenario_name", "gen_type"], as_index=False).value.sum() |
| 166 | + scaling = df[df["scenario_name"] == 1.94][["gen_type", "value"]].rename(columns={"value": "scaling"}) |
| 167 | + df = df.merge(scaling, on="gen_type") |
| 168 | + df.value /= df.scaling |
| 169 | + df.value = (df.value - 1) * 100 |
| 170 | + df = df[df["gen_type"].isin(("Wind", "Solar", "Biomass"))] |
| 171 | + df = df.pivot(index="scenario_name", columns="gen_type", values="value") |
| 172 | + df = df.rename_axis("Technology", axis=1) |
| 173 | + df.plot( |
| 174 | + ax=ax, |
| 175 | + color=tools.get_colors(), |
| 176 | + legend=False |
| 177 | + ) |
| 178 | + ax.set_ylabel("Percent Change in Installed Capacity against Baseline") |
| 179 | + ax.yaxis.set_major_formatter(PercentFormatter()) |
| 180 | + ax.set_xlabel(X_LABEL) |
| 181 | + |
| 182 | + |
| 183 | +def filter_scenario(df, scenario_name): |
| 184 | + return df[df["scenario_name"] == scenario_name].drop(columns=["scenario_name", "scenario_index"]).copy() |
| 185 | + |
| 186 | + |
| 187 | +def figure_2_energy_balance(tools, ax, scenario_name): |
| 188 | + # Get dispatch dataframe |
| 189 | + dispatch = tools.get_dataframe("dispatch.csv", usecols=[ |
| 190 | + "timestamp", "gen_tech", "gen_energy_source", "DispatchGen_MW" |
| 191 | + ]).rename({"DispatchGen_MW": "value"}, axis=1) |
| 192 | + dispatch = filter_scenario(dispatch, scenario_name) |
| 193 | + dispatch = tools.transform.gen_type(dispatch) |
| 194 | + |
| 195 | + # Sum dispatch across all the projects of the same type and timepoint |
| 196 | + dispatch = dispatch.groupby(["timestamp", "gen_type"], as_index=False).sum() |
| 197 | + dispatch = dispatch[dispatch["gen_type"] != "Storage"] |
| 198 | + |
| 199 | + # Get load dataframe |
| 200 | + load = tools.get_dataframe("load_balance.csv", usecols=[ |
| 201 | + "timestamp", "zone_demand_mw", "TXPowerNet" |
| 202 | + ]) |
| 203 | + load = filter_scenario(load, scenario_name) |
| 204 | + |
| 205 | + def process_time(df): |
| 206 | + df = df.astype({"period": int}) |
| 207 | + df = df[df["period"] == df["period"].max()].drop(columns="period") |
| 208 | + return df.set_index("datetime") |
| 209 | + |
| 210 | + # Sum load across all the load zones |
| 211 | + load = load.groupby("timestamp", as_index=False).sum() |
| 212 | + |
| 213 | + # Include Tx Losses in demand and flip sign |
| 214 | + load["value"] = (load["zone_demand_mw"] + load["TXPowerNet"]) * -1 |
| 215 | + |
| 216 | + # Rename and convert from wide to long format |
| 217 | + load = load[["timestamp", "value"]] |
| 218 | + |
| 219 | + # Add the timestamp information and make period string to ensure it doesn't mess up the graphing |
| 220 | + dispatch = process_time(tools.transform.timestamp(dispatch)) |
| 221 | + load = process_time(tools.transform.timestamp(load)) |
| 222 | + |
| 223 | + # Convert to TWh (incl. multiply by timepoint duration) |
| 224 | + dispatch["value"] *= dispatch["tp_duration"] / 1e6 |
| 225 | + load["value"] *= load["tp_duration"] / 1e6 |
| 226 | + |
| 227 | + days = 14 |
| 228 | + freq = str(days) + "D" |
| 229 | + offset = tools.pd.Timedelta(freq) / 2 |
| 230 | + |
| 231 | + def rolling_sum(df): |
| 232 | + df = df.rolling(freq, center=True).value.sum().reset_index() |
| 233 | + df["value"] /= days |
| 234 | + df = df[(df.datetime.min() + offset < df.datetime) & (df.datetime < df.datetime.max() - offset)] |
| 235 | + return df |
| 236 | + |
| 237 | + dispatch = rolling_sum(dispatch.groupby("gen_type", as_index=False)) |
| 238 | + load = rolling_sum(load).set_index("datetime")["value"] |
| 239 | + |
| 240 | + # Get the state of charge data |
| 241 | + soc = tools.get_dataframe("StateOfCharge.csv", dtype={"STORAGE_GEN_TPS_1": str}) \ |
| 242 | + .rename(columns={"STORAGE_GEN_TPS_2": "timepoint", "StateOfCharge": "value"}) |
| 243 | + soc = filter_scenario(soc, scenario_name) |
| 244 | + # Sum over all the projects that are in the same scenario with the same timepoint |
| 245 | + soc = soc.groupby(["timepoint"], as_index=False).sum() |
| 246 | + soc["value"] /= 1e6 # Convert to TWh |
| 247 | + max_soc = soc["value"].max() |
| 248 | + |
| 249 | + # Group by time |
| 250 | + soc = process_time(tools.transform.timestamp(soc, use_timepoint=True, key_col="timepoint")) |
| 251 | + soc = soc.rolling(freq, center=True)["value"].mean().reset_index() |
| 252 | + soc = soc[(soc.datetime.min() + offset < soc.datetime) & (soc.datetime < soc.datetime.max() - offset)] |
| 253 | + soc = soc.set_index("datetime")["value"] |
| 254 | + |
| 255 | + dispatch = dispatch[dispatch["value"] != 0] |
| 256 | + dispatch = dispatch.pivot(columns="gen_type", index="datetime", values="value") |
| 257 | + dispatch = dispatch[dispatch.std().sort_values().index].rename_axis("Technology", axis=1) |
| 258 | + total_dispatch = dispatch.sum(axis=1) |
| 259 | + |
| 260 | + max_val = max(total_dispatch.max(), load.max()) |
| 261 | + |
| 262 | + # Scale soc to the graph |
| 263 | + soc *= 100 / scenario_name |
| 264 | + |
| 265 | + # Plot |
| 266 | + # Get the colors for the lines |
| 267 | + # plot |
| 268 | + ax.set_ylim(0, max_val * 1.05) |
| 269 | + dispatch.plot( |
| 270 | + ax=ax, |
| 271 | + color=tools.get_colors(), |
| 272 | + legend=False, |
| 273 | + xlabel="" |
| 274 | + ) |
| 275 | + ax2 = ax.twinx() |
| 276 | + ax2.yaxis.set_major_formatter(PercentFormatter()) |
| 277 | + ax2.set_ylim(0, 100) |
| 278 | + soc.plot(ax=ax2, color="black", linestyle="dotted", label="State of Charge", xlabel="") |
| 279 | + load.plot(ax=ax, color="red", linestyle="dashed", label="Total Demand", xlabel="") |
| 280 | + total_dispatch.plot(ax=ax, color="green", linestyle="dashed", label="Total Generation", xlabel="") |
| 281 | + ax.fill_between(total_dispatch.index, total_dispatch.values, load.values, alpha=0.2, where=load < total_dispatch, |
| 282 | + facecolor="green") |
| 283 | + ax.fill_between(total_dispatch.index, total_dispatch.values, load.values, alpha=0.2, where=load > total_dispatch, |
| 284 | + facecolor="red") |
| 285 | + ax.set_title(str(scenario_name) + "TWh of storage") |
| 286 | + |
| 287 | + |
| 288 | +@graph( |
| 289 | + "figure_3", |
| 290 | + title="Figure 3: Map of buildout", |
| 291 | + supports_multi_scenario=True |
| 292 | +) |
| 293 | +def figure_3(tools): |
| 294 | + fig = tools.get_figure(size=(24, 12)) |
| 295 | + axes = fig.subplots(2, 4) |
| 296 | + |
| 297 | + plot_buildout(tools, 1.94, axes[0][0]) |
| 298 | + plot_buildout(tools, 4, axes[0][1]) |
| 299 | + plot_buildout(tools, 16, axes[0][2]) |
| 300 | + plot_buildout(tools, 64, axes[0][3]) |
| 301 | + |
| 302 | + dispatch_map(tools, 1.94, axes[1][0]) |
| 303 | + dispatch_map(tools, 4, axes[1][1]) |
| 304 | + dispatch_map(tools, 16, axes[1][2]) |
| 305 | + dispatch_map(tools, 64, axes[1][3]) |
| 306 | + |
| 307 | + |
| 308 | +def plot_buildout(tools, scenario_name, ax): |
| 309 | + buildout = tools.get_dataframe("gen_cap.csv").rename({"GenCapacity": "value"}, axis=1) |
| 310 | + buildout = filter_scenario(buildout, scenario_name) |
| 311 | + buildout = tools.transform.gen_type(buildout) |
| 312 | + buildout = buildout.groupby(["gen_type", "gen_load_zone"], as_index=False)["value"].sum() |
| 313 | + tools.maps.graph_pie_chart(buildout, ax=ax, max_size=1500) |
| 314 | + transmission = tools.get_dataframe("transmission.csv", convert_dot_to_na=True).fillna(0) |
| 315 | + transmission = filter_scenario(transmission, scenario_name) |
| 316 | + transmission = transmission.rename({"trans_lz1": "from", "trans_lz2": "to", "BuildTx": "value"}, axis=1) |
| 317 | + transmission = transmission[["from", "to", "value", "PERIOD"]] |
| 318 | + transmission = transmission.groupby(["from", "to", "PERIOD"], as_index=False).sum().drop("PERIOD", axis=1) |
| 319 | + # Rename the columns appropriately |
| 320 | + transmission.value *= 1e-3 |
| 321 | + tools.maps.graph_transmission(transmission, cutoff=0.1, ax=ax, zorder=50, legend=False) |
| 322 | + ax.set_title(str(scenario_name) + "TWh of storage") |
| 323 | + |
| 324 | +def dispatch_map(tools, scenario_name, ax): |
| 325 | + dispatch = tools.get_dataframe("transmission_dispatch.csv") |
| 326 | + dispatch = filter_scenario(dispatch, scenario_name) |
| 327 | + dispatch = tools.transform.timestamp(dispatch).astype({"period": int}) |
| 328 | + # Keep only the last period |
| 329 | + last_period = dispatch["period"].max() |
| 330 | + dispatch = dispatch[dispatch["period"] == last_period] |
| 331 | + dispatch = dispatch.rename({"load_zone_from": "from", "load_zone_to": "to", "transmission_dispatch": "value"}, |
| 332 | + axis=1) |
| 333 | + dispatch["value"] *= dispatch["tp_duration"] * 1e-6 # Change from power value to energy value |
| 334 | + dispatch = dispatch.groupby(["from", "to"], as_index=False)["value"].sum() |
| 335 | + ax = tools.maps.graph_transmission(dispatch, cutoff=1, ax=ax) |
| 336 | + exports = dispatch[["from", "value"]].rename({"from": "gen_load_zone"}, axis=1).copy() |
| 337 | + imports = dispatch[["to", "value"]].rename({"to": "gen_load_zone"}, axis=1).copy() |
| 338 | + imports["value"] *= -1 |
| 339 | + exports = pd.concat([imports, exports]) |
| 340 | + exports = exports.groupby("gen_load_zone", as_index=False).sum() |
| 341 | + tools.maps.graph_points(exports, ax) |
| 342 | + |
| 343 | + |
| 344 | +if __name__ == "__main__": |
| 345 | + baseline = Scenario("1342", name=1.94) |
| 346 | + min_4 = Scenario("M6", name=4) |
| 347 | + min_8 = Scenario("M5", name=8) |
| 348 | + min_16 = Scenario("M4", name=16) |
| 349 | + min_32 = Scenario("M3", name=32) |
| 350 | + min_64 = Scenario("M2", name=64) |
| 351 | + minimum_scenarios = [baseline, min_4, min_8, min_16, min_32, min_64] |
| 352 | + kwargs = dict(graph_dir="LDES_paper_graphs", overwrite=True, module_names=[]) |
| 353 | + # graph_scenarios( |
| 354 | + # scenarios=minimum_scenarios, |
| 355 | + # figures=["figure_2"], |
| 356 | + # **kwargs |
| 357 | + # ) |
| 358 | + graph_scenarios( |
| 359 | + scenarios=[baseline, min_4, min_16, min_64], |
| 360 | + figures=["figure_3"], |
| 361 | + **kwargs |
| 362 | + ) |
0 commit comments