Skip to content

Commit 90bb085

Browse files
committed
feat: add plot_power_flow
1 parent 103c285 commit 90bb085

File tree

1 file changed

+171
-1
lines changed

1 file changed

+171
-1
lines changed

src/pownet/core/visualizer.py

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import geopandas as gpd
77
import matplotlib as mpl
88
from matplotlib.colors import ListedColormap
9+
from matplotlib.lines import Line2D
910
import matplotlib.pyplot as plt
1011
import numpy as np
1112
import pandas as pd
@@ -62,7 +63,7 @@ def plot_fuelmix_bar(
6263
bbox_to_anchor=(0.5, -0.12),
6364
)
6465
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))
6667

6768
if output_folder is not None:
6869
figure_name = f"{self.model_id}_fuelmix.png"
@@ -396,3 +397,172 @@ def plot_generation_by_contracts(self, contract_generation: pd.DataFrame) -> Non
396397
df_to_plot.plot(ax=ax, kind="bar", linewidth=2, legend=False)
397398
ax.set_ylabel("Generation (MWh)")
398399
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

Comments
 (0)