Skip to content

Commit ff15142

Browse files
committed
Add matplotlib plotting for asset optimization
A function that can be used to visualize the power flow between batteries, power generating and consuming assets in a stacked view is provided. A second function can be used to plot the excess and deficit net power, battery charge and battery SoC. Finally functionality to plot monthly aggregates is added. All functions in this module are based on matplotlib. Signed-off-by: cwasicki <[email protected]>
1 parent 8d1685d commit ff15142

File tree

1 file changed

+246
-0
lines changed
  • src/frequenz/lib/notebooks/reporting/asset_optimization

1 file changed

+246
-0
lines changed
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Visualization for asset optimization reporting using matplotlib."""
5+
6+
7+
import logging
8+
9+
import matplotlib.pyplot as plt
10+
import pandas as pd
11+
from matplotlib.axes import Axes
12+
13+
_logger = logging.getLogger(__name__)
14+
15+
16+
FIGURE_SIZE = (30, 6.66) # Default figure size for plots
17+
18+
19+
def require_columns(df: pd.DataFrame, *columns: str) -> None:
20+
"""Ensure that the DataFrame contains the required columns."""
21+
missing = [col for col in columns if col not in df.columns]
22+
if missing:
23+
raise ValueError(f"DataFrame is missing required columns: {', '.join(missing)}")
24+
25+
26+
def plot_power_flow(df: pd.DataFrame, ax: Axes | None = None) -> None:
27+
"""Plot the power flow of the microgrid."""
28+
require_columns(df, "consumption", "battery", "grid")
29+
d = -df.copy()
30+
i = d.index
31+
cons = -d["consumption"].to_numpy()
32+
33+
has_chp = "chp" in d.columns
34+
has_pv = "pv" in d.columns
35+
chp = d["chp"] if has_chp else 0 * cons
36+
prod = chp + (d["pv"].clip(lower=0) if has_pv else 0)
37+
38+
if ax is None:
39+
fig, ax = plt.subplots(figsize=FIGURE_SIZE)
40+
41+
if has_pv:
42+
ax.fill_between(
43+
i,
44+
chp,
45+
prod,
46+
color="gold",
47+
alpha=0.7,
48+
label="PV" + (" (on CHP)" if has_chp else ""),
49+
)
50+
if has_chp:
51+
ax.fill_between(i, chp, color="cornflowerblue", alpha=0.5, label="CHP")
52+
53+
if "battery" in d.columns:
54+
bat_cons = -(d["consumption"].to_numpy() + d["battery"].to_numpy())
55+
charge = bat_cons > cons
56+
discharge = bat_cons < cons
57+
ax.fill_between(
58+
i,
59+
cons,
60+
bat_cons,
61+
where=charge,
62+
color="green",
63+
alpha=0.2,
64+
label="Charge",
65+
)
66+
ax.fill_between(
67+
i,
68+
cons,
69+
bat_cons,
70+
where=discharge,
71+
color="lightcoral",
72+
alpha=0.5,
73+
label="Discharge",
74+
)
75+
76+
if "grid" in d.columns:
77+
ax.plot(i, -d["grid"], color="grey", label="Grid")
78+
79+
ax.plot(i, cons, "k-", label="Consumption")
80+
ax.set_ylabel("Power [kW]")
81+
ax.legend()
82+
ax.grid(True)
83+
ax.set_ylim(bottom=min(0, ax.get_ylim()[0]))
84+
85+
86+
def plot_energy_trade(df: pd.DataFrame, ax: Axes | None = None) -> None:
87+
"""Plot the energy trade of the microgrid."""
88+
require_columns(df, "consumption")
89+
d = -df.copy()
90+
cons = -d["consumption"]
91+
trade = cons.copy()
92+
93+
has_chp = "chp" in d.columns
94+
has_pv = "pv" in d.columns
95+
chp = d["chp"] if has_chp else 0 * cons
96+
prod = chp + (d["pv"].clip(lower=0) if has_pv else 0)
97+
trade -= prod
98+
99+
g = trade.resample("15min").mean() / 4
100+
101+
if ax is None:
102+
fig, ax = plt.subplots(figsize=FIGURE_SIZE)
103+
ax.fill_between(
104+
g.index, 0, g.clip(lower=0).to_numpy(), color="darkred", label="Buy", step="pre"
105+
)
106+
ax.fill_between(
107+
g.index,
108+
0,
109+
g.clip(upper=0).to_numpy(),
110+
color="darkgreen",
111+
label="Sell",
112+
step="pre",
113+
)
114+
ax.set_ylabel("Energy [kWh]")
115+
ax.legend()
116+
ax.grid(True)
117+
118+
119+
def plot_power_flow_trade(df: pd.DataFrame) -> None:
120+
"""Plot both power flow and energy trade of the microgrid."""
121+
fig, (ax1, ax2) = plt.subplots(
122+
2, 1, figsize=FIGURE_SIZE, sharex=True, height_ratios=[4, 1]
123+
)
124+
plot_power_flow(df, ax=ax1)
125+
plot_energy_trade(df, ax=ax2)
126+
plt.tight_layout()
127+
plt.show()
128+
129+
130+
def plot_battery_power(df: pd.DataFrame) -> None:
131+
"""Plot the battery power and state of charge (SOC) of the microgrid."""
132+
require_columns(df, "battery", "grid", "soc")
133+
134+
fig, ax1 = plt.subplots(figsize=FIGURE_SIZE)
135+
136+
# Plot Battery SOC
137+
twin_ax = ax1.twinx()
138+
assert df["soc"].ndim == 1, "SOC data should be 1D"
139+
soc = df["soc"]
140+
twin_ax.grid(False)
141+
twin_ax.fill_between(
142+
df.index,
143+
soc.to_numpy() * 0,
144+
soc.to_numpy(),
145+
color="grey",
146+
alpha=0.4,
147+
label="SOC",
148+
)
149+
twin_ax.set_ylim(0, 100)
150+
twin_ax.set_ylabel("Battery SOC", fontsize=14)
151+
twin_ax.tick_params(axis="y", labelcolor="grey", labelsize=14)
152+
153+
# Available power
154+
available = df["battery"] - df["grid"]
155+
ax1.plot(
156+
df.index,
157+
available,
158+
color="black",
159+
linestyle="-",
160+
label="Available power",
161+
alpha=1,
162+
)
163+
164+
# Plot Battery Power on primary y-axis
165+
ax1.axhline(y=0, color="grey", linestyle="--", alpha=0.5)
166+
# Make battery power range symmetric
167+
max_abs_bat = max(
168+
abs(df["battery"].min()),
169+
abs(df["battery"].max()),
170+
abs(available.min()),
171+
abs(available.max()),
172+
)
173+
ax1.set_ylim(-max_abs_bat * 1.1, max_abs_bat * 1.1)
174+
ax1.set_ylabel("Battery Power", fontsize=14)
175+
ax1.tick_params(axis="y", labelcolor="black", labelsize=14)
176+
177+
# Fill Battery Power around zero (reverse sign)
178+
ax1.fill_between(
179+
df.index,
180+
0,
181+
df["battery"],
182+
where=(df["battery"].to_numpy() > 0).tolist(),
183+
interpolate=False,
184+
color="green",
185+
alpha=0.9,
186+
label="Charge",
187+
)
188+
ax1.fill_between(
189+
df.index,
190+
0,
191+
df["battery"],
192+
where=(df["battery"].to_numpy() <= 0).tolist(),
193+
interpolate=False,
194+
color="red",
195+
alpha=0.9,
196+
label="Discharge",
197+
)
198+
199+
fig.tight_layout()
200+
fig.legend(loc="upper left", fontsize=14)
201+
plt.show()
202+
203+
204+
def plot_monthly(df: pd.DataFrame) -> pd.DataFrame:
205+
"""Plot monthly aggregate data."""
206+
months: pd.DataFrame = df.resample("1MS").sum()
207+
resolution = (df.index[1] - df.index[0]).total_seconds()
208+
kW2MWh = resolution / 3600 / 1000 # pylint: disable=invalid-name
209+
months *= kW2MWh
210+
# Ensure the index is a datetime
211+
if not isinstance(months.index, pd.DatetimeIndex):
212+
months.index = pd.to_datetime(months.index)
213+
months.index = pd.Index(months.index.date)
214+
pos, neg = (
215+
months[[c for c in months.columns if "_pos" in c]],
216+
months[[c for c in months.columns if "_neg" in c]],
217+
)
218+
219+
pos = pos.rename(
220+
columns={
221+
"grid_pos": "Grid Consumption",
222+
"battery_pos": "Battery Charge",
223+
"consumption_pos": "Consumption",
224+
"pv_pos": "PV Consumption",
225+
"chp_pos": "CHP Consumption",
226+
}
227+
)
228+
neg = neg.rename(
229+
columns={
230+
"grid_neg": "Grid Feed-in",
231+
"battery_neg": "Battery Discharge",
232+
"consumption_neg": "Unknown Production",
233+
"pv_neg": "PV Production",
234+
"chp_neg": "CHP Production",
235+
}
236+
)
237+
238+
# Remove zero columns
239+
pos = pos.loc[:, pos.abs().sum(axis=0) > 0]
240+
neg = neg.loc[:, neg.abs().sum(axis=0) > 0]
241+
242+
ax = pos.plot.bar()
243+
neg.plot.bar(ax=ax, alpha=0.7)
244+
plt.xticks(rotation=0)
245+
plt.ylabel("Energy [MWh]")
246+
return months

0 commit comments

Comments
 (0)