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