|
6 | 6 | import geopandas as gpd |
7 | 7 | import matplotlib as mpl |
8 | 8 | from matplotlib.colors import ListedColormap |
| 9 | +from matplotlib.lines import Line2D |
9 | 10 | import matplotlib.pyplot as plt |
10 | 11 | import numpy as np |
11 | 12 | import pandas as pd |
@@ -62,7 +63,7 @@ def plot_fuelmix_bar( |
62 | 63 | bbox_to_anchor=(0.5, -0.12), |
63 | 64 | ) |
64 | 65 | ax.set_ylabel("Power (MW)") |
65 | | - ax.set_ylim(top=(demand[:total_timesteps].max() * 1.30).values[0]) |
| 66 | + ax.set_ylim(top=(demand[:total_timesteps].max() * 1.30)) |
66 | 67 |
|
67 | 68 | if output_folder is not None: |
68 | 69 | figure_name = f"{self.model_id}_fuelmix.png" |
@@ -396,3 +397,172 @@ def plot_generation_by_contracts(self, contract_generation: pd.DataFrame) -> Non |
396 | 397 | df_to_plot.plot(ax=ax, kind="bar", linewidth=2, legend=False) |
397 | 398 | ax.set_ylabel("Generation (MWh)") |
398 | 399 | plt.show() |
| 400 | + |
| 401 | + def plot_power_flow(self, |
| 402 | + flow_variables: pd.DataFrame, |
| 403 | + figsize_per_line: tuple = (10, 2), # Note: height is for plot area of one subplot |
| 404 | + fixed_legend_height_inches: float = 0.5) -> None: |
| 405 | + """ |
| 406 | + Plots the power flow on transmission lines over time. |
| 407 | +
|
| 408 | + Each unique transmission line (node_a to node_b) gets its own subplot. |
| 409 | + The y-axis label for each subplot is the line segment name. |
| 410 | + Legend is placed at the top center of the figure, occupying a fixed absolute height. |
| 411 | + Power flow is colored: |
| 412 | + - Green: Positive flow |
| 413 | + - Red: Negative flow |
| 414 | + - Black: Zero flow |
| 415 | +
|
| 416 | + Args: |
| 417 | + flow_variables (pd.DataFrame): DataFrame with simulation results. Expected columns: |
| 418 | + 'node_a', 'node_b', 'value', 'type' ('fwd' or 'bwd'), 'hour'. |
| 419 | + figsize_per_line (tuple): Tuple specifying (width, height_for_each_subplot_plot_area). |
| 420 | + fixed_legend_height_inches (float): Absolute height in inches for the legend area at the top. |
| 421 | + """ |
| 422 | + lines = flow_variables[['node_a', 'node_b']].drop_duplicates().values.tolist() |
| 423 | + num_lines = len(lines) |
| 424 | + |
| 425 | + if num_lines == 0: |
| 426 | + print("No transmission lines to plot.") |
| 427 | + return |
| 428 | + |
| 429 | + fig_width = figsize_per_line[0] |
| 430 | + |
| 431 | + # Calculate the total height needed for the plot areas of all subplots |
| 432 | + plots_area_height_inches = figsize_per_line[1] * num_lines |
| 433 | + if plots_area_height_inches < 0: # Ensure non-negative plot height |
| 434 | + plots_area_height_inches = 0 |
| 435 | + |
| 436 | + # Calculate the total figure height, including the fixed space for the legend |
| 437 | + total_figure_height_inches = plots_area_height_inches + fixed_legend_height_inches |
| 438 | + |
| 439 | + # Ensure total figure height is positive; if not, default to a minimum |
| 440 | + if total_figure_height_inches <= 0: |
| 441 | + total_figure_height_inches = max(fixed_legend_height_inches, 1.0) # Use legend height or 1 inch min |
| 442 | + |
| 443 | + fig, axes = plt.subplots(num_lines, 1, |
| 444 | + figsize=(fig_width, total_figure_height_inches), # Use the calculated total height |
| 445 | + sharex=True, squeeze=False) |
| 446 | + |
| 447 | + # Calculate the fraction of the total figure height that the legend area will occupy |
| 448 | + if total_figure_height_inches > 0: # Avoid division by zero |
| 449 | + legend_top_margin_fraction = fixed_legend_height_inches / total_figure_height_inches |
| 450 | + else: # Should be unreachable due to the check above |
| 451 | + legend_top_margin_fraction = 0.5 # Fallback: 50% for legend if total height is still 0 |
| 452 | + |
| 453 | + # Ensure legend fraction is reasonable (e.g., not more than 80% if there are plots) |
| 454 | + # This prevents plots from being overly squished if their requested height is tiny. |
| 455 | + if plots_area_height_inches > 0 and legend_top_margin_fraction > 0.8: |
| 456 | + legend_top_margin_fraction = 0.8 |
| 457 | + |
| 458 | + |
| 459 | + for i, line_nodes in enumerate(lines): |
| 460 | + ax = axes[i, 0] |
| 461 | + node_a, node_b = line_nodes |
| 462 | + line_segment_name = f"{node_a} to {node_b}\nPower flow (MW)" |
| 463 | + |
| 464 | + line_df = flow_variables[(flow_variables['node_a'] == node_a) & (flow_variables['node_b'] == node_b)] |
| 465 | + |
| 466 | + if line_df.empty: |
| 467 | + ax.set_title(f"Power Flow: {line_segment_name} (No data)") |
| 468 | + ax.set_ylabel(line_segment_name) |
| 469 | + ax.text(0.5, 0.5, 'No data for this line', |
| 470 | + horizontalalignment='center', verticalalignment='center', |
| 471 | + transform=ax.transAxes) |
| 472 | + continue |
| 473 | + |
| 474 | + try: |
| 475 | + pivot_df = line_df.pivot_table(index='hour', columns='type', values='value', fill_value=0) |
| 476 | + except Exception as e: |
| 477 | + ax.set_title(f"Power Flow: {line_segment_name} (Error pivoting data)") |
| 478 | + ax.set_ylabel(line_segment_name) |
| 479 | + ax.text(0.5, 0.5, f'Error processing data: {e}', |
| 480 | + horizontalalignment='center', verticalalignment='center', |
| 481 | + transform=ax.transAxes) |
| 482 | + continue |
| 483 | + |
| 484 | + if 'fwd' not in pivot_df.columns: |
| 485 | + pivot_df['fwd'] = 0 |
| 486 | + if 'bwd' not in pivot_df.columns: |
| 487 | + pivot_df['bwd'] = 0 |
| 488 | + |
| 489 | + pivot_df = pivot_df.sort_index() |
| 490 | + net_flow = pivot_df['fwd'] - pivot_df['bwd'] |
| 491 | + hours = net_flow.index |
| 492 | + |
| 493 | + if len(hours) < 2: |
| 494 | + if len(hours) == 1: |
| 495 | + y_val = net_flow.iloc[0] |
| 496 | + color = 'green' if y_val > 0 else ('red' if y_val < 0 else 'black') |
| 497 | + ax.plot(hours[0], y_val, marker='o', color=color) |
| 498 | + ax.set_title(f"Power Flow: {line_segment_name} (Not enough data for line plot)") |
| 499 | + ax.set_ylabel(line_segment_name) |
| 500 | + if len(hours) > 0: |
| 501 | + ax.set_xlim(hours.min() -1 if pd.api.types.is_numeric_dtype(hours) else hours.min() - pd.Timedelta(days=1), |
| 502 | + hours.max() + 1 if pd.api.types.is_numeric_dtype(hours) else hours.max() + pd.Timedelta(days=1)) |
| 503 | + continue |
| 504 | + |
| 505 | + for j in range(len(hours) - 1): |
| 506 | + x1, x2 = hours[j], hours[j+1] |
| 507 | + y1, y2 = net_flow.iloc[j], net_flow.iloc[j+1] |
| 508 | + |
| 509 | + x1_num = pd.to_numeric(x1) |
| 510 | + x2_num = pd.to_numeric(x2) |
| 511 | + |
| 512 | + if y1 == 0 and y2 == 0: |
| 513 | + ax.plot([x1, x2], [y1, y2], color='black', linestyle='-') |
| 514 | + elif y1 * y2 >= 0: |
| 515 | + if y1 == 0: |
| 516 | + color = 'green' if y2 > 0 else ('red' if y2 < 0 else 'black') |
| 517 | + elif y2 == 0: |
| 518 | + color = 'green' if y1 > 0 else ('red' if y1 < 0 else 'black') |
| 519 | + else: |
| 520 | + color = 'green' if y1 > 0 else 'red' |
| 521 | + ax.plot([x1, x2], [y1, y2], color=color, linestyle='-') |
| 522 | + else: |
| 523 | + if (y2 - y1) == 0: |
| 524 | + x_intersect_num = x1_num |
| 525 | + else: |
| 526 | + x_intersect_num = x1_num - y1 * (x2_num - x1_num) / (y2 - y1) |
| 527 | + |
| 528 | + if isinstance(x1, (pd.Timestamp, np.datetime64)): |
| 529 | + x_intersect = pd.Timestamp(x_intersect_num) |
| 530 | + elif isinstance(x1, pd.Timedelta) or isinstance(x1, np.timedelta64): |
| 531 | + x_intersect = pd.Timedelta(x_intersect_num, unit='ns') |
| 532 | + else: |
| 533 | + x_intersect = x_intersect_num |
| 534 | + |
| 535 | + color1 = 'green' if y1 > 0 else 'red' |
| 536 | + ax.plot([x1, x_intersect], [y1, 0], color=color1, linestyle='-') |
| 537 | + color2 = 'green' if y2 > 0 else 'red' |
| 538 | + ax.plot([x_intersect, x2], [0, y2], color=color2, linestyle='-') |
| 539 | + |
| 540 | + ax.axhline(0, color='gray', linestyle='--', linewidth=0.8) |
| 541 | + # ax.set_title(f"Power Flow: {line_segment_name}") |
| 542 | + ax.set_ylabel(line_segment_name) |
| 543 | + |
| 544 | + if num_lines > 0: |
| 545 | + axes[-1, 0].set_xlabel("Hour") |
| 546 | + |
| 547 | + legend_elements = [Line2D([0], [0], color='green', lw=2, label='Positive Flow'), |
| 548 | + Line2D([0], [0], color='red', lw=2, label='Negative Flow'), |
| 549 | + Line2D([0], [0], color='black', lw=2, label='Zero Flow')] |
| 550 | + |
| 551 | + fig.tight_layout() |
| 552 | + |
| 553 | + # Adjust the top of the subplots area to make space for the legend. |
| 554 | + # The subplots will occupy the space from y=0 to y=(1 - legend_top_margin_fraction) |
| 555 | + fig.subplots_adjust(top=(1 - legend_top_margin_fraction)) |
| 556 | + |
| 557 | + # Define the bounding box for the legend at the top of the figure |
| 558 | + # y coordinate for bbox starts where subplots end and goes up by legend_top_margin_fraction |
| 559 | + legend_bbox_y_start = 1 - legend_top_margin_fraction |
| 560 | + legend_bbox = (0, legend_bbox_y_start, 1, legend_top_margin_fraction) |
| 561 | + |
| 562 | + fig.legend(handles=legend_elements, |
| 563 | + loc='center', |
| 564 | + bbox_to_anchor=legend_bbox, |
| 565 | + ncol=3, |
| 566 | + frameon=False) |
| 567 | + |
| 568 | + plt.show() |
0 commit comments