Skip to content

Commit 25abc44

Browse files
committed
Add asset optimization reporting module
Signed-off-by: cwasicki <[email protected]>
1 parent b0e1329 commit 25abc44

File tree

1 file changed

+330
-0
lines changed

1 file changed

+330
-0
lines changed
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Reporting for asset optimization."""
5+
6+
7+
from datetime import datetime, timedelta
8+
9+
import matplotlib.pyplot as plt
10+
import numpy as np
11+
import pandas as pd
12+
from matplotlib.axes import Axes
13+
14+
from frequenz.data.microgrid import MicrogridData
15+
16+
17+
# pylint: disable=too-many-arguments
18+
async def fetch_data(
19+
mdata: MicrogridData,
20+
*,
21+
component_types: tuple[str],
22+
mid: int,
23+
start_time: datetime,
24+
end_time: datetime,
25+
resampling_period: timedelta,
26+
splits: bool = False,
27+
fetch_soc: bool = False,
28+
) -> pd.DataFrame:
29+
"""
30+
Fetch data of a microgrid and processes it for plotting.
31+
32+
Args:
33+
mdata: MicrogridData object to fetch data from.
34+
component_types: List of component types to fetch data for.
35+
mid: Microgrid ID.
36+
start_time: Start time for data fetching.
37+
end_time: End time for data fetching.
38+
resampling_period: Time resolution for data fetching.
39+
splits: Whether to split the data into positive and negative parts.
40+
fetch_soc: Whether to fetch state of charge (SOC) data.
41+
42+
Returns:
43+
pd.DataFrame: DataFrame containing the processed data.
44+
45+
Raises:
46+
ValueError: If no data is found for the given microgrid and time range or if
47+
unexpected component types are present in the data.
48+
"""
49+
print(
50+
f"Requesting data from {start_time} to {end_time} at {resampling_period} resolution"
51+
)
52+
df = await mdata.ac_active_power(
53+
microgrid_id=mid,
54+
component_types=component_types,
55+
start=start_time,
56+
end=end_time,
57+
resampling_period=resampling_period,
58+
keep_components=False,
59+
splits=splits,
60+
unit="kW",
61+
)
62+
if df is None or df.empty:
63+
raise ValueError(
64+
f"No data found for microgrid {mid} between {start_time} and {end_time}"
65+
)
66+
67+
print(f"Received {df.shape[0]} rows and {df.shape[1]} columns")
68+
69+
if fetch_soc:
70+
soc_df = await mdata.soc(
71+
microgrid_id=mid,
72+
start=start_time,
73+
end=end_time,
74+
resampling_period=resampling_period,
75+
keep_components=False,
76+
)
77+
if soc_df is None or soc_df.empty:
78+
raise ValueError(
79+
f"No SOC data found for microgrid {mid} between {start_time} and {end_time}"
80+
)
81+
df = pd.concat([df, soc_df.rename(columns={"battery": "soc"})[["soc"]]], axis=1)
82+
83+
df["soc"] = df.get("soc", np.nan)
84+
# For later vizualization we default to zero
85+
df["battery"] = df.get("battery", 0)
86+
df["chp"] = df["chp"].clip(upper=0) if "chp" in df.columns else 0
87+
df["pv"] = df["pv"].clip(upper=0) if "pv" in df.columns else 0
88+
89+
# Determine consumption if not present
90+
if "consumption" not in df.columns:
91+
if any(
92+
ct not in ["grid", "pv", "battery", "chp", "consumption"]
93+
for ct in df.columns
94+
):
95+
raise ValueError(
96+
"Consumption not found in data and unexpected component types present."
97+
)
98+
df["consumption"] = df["grid"] - (df["chp"] + df["pv"] + df["battery"])
99+
100+
return df
101+
102+
103+
def plot_power_flow(df: pd.DataFrame, ax: Axes | None = None) -> None:
104+
"""Plot the power flow of the microgrid."""
105+
d = -df.copy()
106+
i = d.index
107+
cons = -d["consumption"].to_numpy()
108+
109+
has_chp = "chp" in d.columns
110+
has_pv = "pv" in d.columns
111+
chp = d["chp"] if has_chp else 0 * cons
112+
prod = chp + (d["pv"].clip(lower=0) if has_pv else 0)
113+
114+
if ax is None:
115+
fig, ax = plt.subplots(figsize=(30, 10), sharex=True)
116+
117+
if has_pv:
118+
ax.fill_between(
119+
i,
120+
chp,
121+
prod,
122+
color="gold",
123+
alpha=0.7,
124+
label="PV" + (" (on CHP)" if has_chp else ""),
125+
)
126+
if has_chp:
127+
ax.fill_between(i, chp, color="cornflowerblue", alpha=0.5, label="CHP")
128+
129+
if "battery" in d.columns:
130+
bat_cons = -(d["consumption"].to_numpy() + d["battery"].to_numpy())
131+
charge = bat_cons > cons
132+
discharge = bat_cons < cons
133+
ax.fill_between(
134+
i,
135+
cons,
136+
bat_cons,
137+
where=charge,
138+
color="green",
139+
alpha=0.2,
140+
label="Charge",
141+
)
142+
ax.fill_between(
143+
i,
144+
cons,
145+
bat_cons,
146+
where=discharge,
147+
color="lightcoral",
148+
alpha=0.5,
149+
label="Discharge",
150+
)
151+
152+
if "grid" in d.columns:
153+
ax.plot(i, -d["grid"], color="grey", label="Grid")
154+
155+
ax.plot(i, cons, "k-", label="Consumption")
156+
ax.set_ylabel("Power [kW]")
157+
ax.legend()
158+
ax.grid(True)
159+
ax.set_ylim(bottom=min(0, ax.get_ylim()[0]))
160+
161+
162+
def plot_energy_trade(df: pd.DataFrame, ax: Axes | None = None) -> None:
163+
"""Plot the energy trade of the microgrid."""
164+
d = -df.copy()
165+
cons = -d["consumption"]
166+
trade = cons.copy()
167+
168+
has_chp = "chp" in d.columns
169+
has_pv = "pv" in d.columns
170+
chp = d["chp"] if has_chp else 0 * cons
171+
prod = chp + (d["pv"].clip(lower=0) if has_pv else 0)
172+
trade -= prod
173+
174+
g = trade.resample("15min").mean() / 4
175+
176+
if ax is None:
177+
fig, ax = plt.subplots(figsize=(30, 10), sharex=True)
178+
ax.fill_between(
179+
g.index, 0, g.clip(lower=0).to_numpy(), color="darkred", label="Buy", step="pre"
180+
)
181+
ax.fill_between(
182+
g.index,
183+
0,
184+
g.clip(upper=0).to_numpy(),
185+
color="darkgreen",
186+
label="Sell",
187+
step="pre",
188+
)
189+
ax.set_ylabel("Energy [kWh]")
190+
ax.legend()
191+
ax.grid(True)
192+
193+
194+
def plot_power_flow_trade(df: pd.DataFrame) -> None:
195+
"""Plot both power flow and energy trade of the microgrid."""
196+
fig, (ax1, ax2) = plt.subplots(
197+
2, 1, figsize=(30, 10), sharex=True, height_ratios=[4, 1]
198+
)
199+
plot_power_flow(df, ax=ax1)
200+
plot_energy_trade(df, ax=ax2)
201+
plt.tight_layout()
202+
plt.show()
203+
204+
205+
def plot_battery_power(df: pd.DataFrame) -> None:
206+
"""Plot the battery power and state of charge (SOC) of the microgrid."""
207+
if "soc" not in df.columns:
208+
raise ValueError(
209+
"DataFrame must contain 'soc' column for battery SOC plotting."
210+
)
211+
212+
fig, ax1 = plt.subplots(figsize=(30, 6.66)) # Increased the figure height
213+
214+
# Plot Battery SOC
215+
twin_ax = ax1.twinx()
216+
assert df["soc"].ndim == 1, "SOC data should be 1D"
217+
soc = df["soc"] # .iloc[:, 0] if df["soc"].ndim > 1 else df["soc"]
218+
twin_ax.grid(False) # Turn off the grid for the SOC plot
219+
twin_ax.fill_between(
220+
df.index,
221+
soc.to_numpy() * 0,
222+
soc.to_numpy(),
223+
color="grey",
224+
alpha=0.4,
225+
label="SOC",
226+
)
227+
twin_ax.set_ylim(0, 100) # Set SOC range between 0 and 100
228+
twin_ax.set_ylabel("Battery SOC", fontsize=14) # Increased the font size
229+
twin_ax.tick_params(
230+
axis="y", labelcolor="grey", labelsize=14
231+
) # Increased the font size for ticks
232+
233+
# Available power
234+
available = df["battery"] - df["grid"]
235+
ax1.plot(
236+
df.index,
237+
available,
238+
color="black",
239+
linestyle="-",
240+
label="Available power",
241+
alpha=1,
242+
)
243+
244+
# Plot Battery Power on primary y-axis
245+
ax1.axhline(y=0, color="grey", linestyle="--", alpha=0.5)
246+
# Make battery power range symmetric
247+
max_abs_bat = max(
248+
abs(df["battery"].min()),
249+
abs(df["battery"].max()),
250+
abs(available.min()),
251+
abs(available.max()),
252+
)
253+
ax1.set_ylim(-max_abs_bat * 1.1, max_abs_bat * 1.1)
254+
ax1.set_ylabel(
255+
"Battery Power", fontsize=14
256+
) # Updated the label to include Grid - Bat
257+
ax1.tick_params(
258+
axis="y", labelcolor="black", labelsize=14
259+
) # Increased the font size for ticks
260+
261+
# Fill Battery Power around zero (reverse sign)
262+
ax1.fill_between(
263+
df.index,
264+
0,
265+
df["battery"],
266+
where=(df["battery"].to_numpy() > 0).tolist(),
267+
interpolate=False,
268+
color="green",
269+
alpha=0.9,
270+
label="Charge",
271+
)
272+
ax1.fill_between(
273+
df.index,
274+
0,
275+
df["battery"],
276+
where=(df["battery"].to_numpy() <= 0).tolist(),
277+
interpolate=False,
278+
color="red",
279+
alpha=0.9,
280+
label="Discharge",
281+
)
282+
283+
fig.tight_layout()
284+
fig.legend(loc="upper left", fontsize=14) # Increased font size for legend
285+
plt.show()
286+
287+
288+
def plot_monthly(df: pd.DataFrame) -> pd.DataFrame:
289+
"""Plot monthly aggregate data."""
290+
months = df.resample("1MS").sum()
291+
resolution = (df.index[1] - df.index[0]).total_seconds()
292+
kW2MWh = resolution / 3600 / 1000 # pylint: disable=invalid-name
293+
months *= kW2MWh
294+
# Ensure the index is a datetime
295+
if not isinstance(months.index, pd.DatetimeIndex):
296+
months.index = pd.to_datetime(months.index)
297+
months.index = pd.Index(months.index.date)
298+
pos, neg = (
299+
months[[c for c in months.columns if "_pos" in c]],
300+
months[[c for c in months.columns if "_neg" in c]],
301+
)
302+
303+
pos = pos.rename(
304+
columns={
305+
"grid_pos": "Grid Consumption",
306+
"battery_pos": "Battery Charge",
307+
"consumption_pos": "Consumption",
308+
"pv_pos": "PV Consumption",
309+
"chp_pos": "CHP Consumption",
310+
}
311+
)
312+
neg = neg.rename(
313+
columns={
314+
"grid_neg": "Grid Feed-in",
315+
"battery_neg": "Battery Discharge",
316+
"consumption_neg": "Unknown Production",
317+
"pv_neg": "PV Production",
318+
"chp_neg": "CHP Production",
319+
}
320+
)
321+
322+
# Remove zero columns
323+
pos = pos.loc[:, pos.abs().sum(axis=0) > 0]
324+
neg = neg.loc[:, neg.abs().sum(axis=0) > 0]
325+
326+
ax = pos.plot.bar()
327+
neg.plot.bar(ax=ax, alpha=0.7)
328+
plt.xticks(rotation=0)
329+
plt.ylabel("Energy [MWh]")
330+
return months

0 commit comments

Comments
 (0)