|
| 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