|
| 1 | +# License: MIT |
| 2 | +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH |
| 3 | + |
| 4 | +"""Energy reporting metrics for production, consumption, and storage analysis. |
| 5 | +
|
| 6 | +This module provides helper functions for calculating and analyzing energy |
| 7 | +flows within a hybrid power system — including production, consumption, |
| 8 | +battery charging, grid feed-in, and self-consumption metrics. |
| 9 | +
|
| 10 | +Key features: |
| 11 | +It standardizes sign conventions (via `production_is_positive`) and ensures |
| 12 | +consistency in derived quantities such as: |
| 13 | + - Production surplus (excess generation) |
| 14 | + - Energy stored in a battery |
| 15 | + - Grid feed-in power |
| 16 | + - Self-consumption and self-consumption share |
| 17 | + - Inferred consumption when not explicitly provided |
| 18 | +
|
| 19 | +Usage: |
| 20 | + These functions serve as building blocks for energy reporting and |
| 21 | + dashboards that report on the performance of energy assets. |
| 22 | +""" |
| 23 | + |
| 24 | +import warnings |
| 25 | + |
| 26 | +import pandas as pd |
| 27 | + |
| 28 | + |
| 29 | +def asset_production( |
| 30 | + production: pd.Series, production_is_positive: bool = False |
| 31 | +) -> pd.Series: |
| 32 | + """Extract the positive production portion from a power series. |
| 33 | +
|
| 34 | + Ensures only productive (non-negative) values remain, regardless of the |
| 35 | + sign convention used for production vs. consumption. |
| 36 | +
|
| 37 | + Args: |
| 38 | + production: Series of power values (e.g., kW or MW). |
| 39 | + production_is_positive: Whether production values are already positive. |
| 40 | + If False, `production` is inverted before clipping. |
| 41 | +
|
| 42 | + Returns: |
| 43 | + A Series where only production values (≥ 0) are retained, with all |
| 44 | + non-productive values set to zero. |
| 45 | + """ |
| 46 | + return ( |
| 47 | + (production if production_is_positive else -production).fillna(0).clip(lower=0) |
| 48 | + ) |
| 49 | + |
| 50 | + |
| 51 | +def production_excess( |
| 52 | + production: pd.Series, consumption: pd.Series, production_is_positive: bool = False |
| 53 | +) -> pd.Series: |
| 54 | + """Compute the excess production relative to consumption. |
| 55 | +
|
| 56 | + Calculates surplus by subtracting consumption from production and removing |
| 57 | + negative results. Production is optionally sign-corrected first. |
| 58 | +
|
| 59 | + Args: |
| 60 | + production: Series of production values (e.g., kW or MW). |
| 61 | + consumption: Series of consumption values (same units as `production`). |
| 62 | + production_is_positive: Whether production values are already positive. |
| 63 | + If False, `production` is inverted before clipping. |
| 64 | +
|
| 65 | + Returns: |
| 66 | + A Series representing excess production (≥ 0). |
| 67 | + """ |
| 68 | + asset_production_series = asset_production( |
| 69 | + production, production_is_positive=production_is_positive |
| 70 | + ) |
| 71 | + return (asset_production_series - consumption.fillna(0)).clip(lower=0) |
| 72 | + |
| 73 | + |
| 74 | +def production_excess_in_bat( |
| 75 | + production: pd.Series, |
| 76 | + consumption: pd.Series, |
| 77 | + battery: pd.Series, |
| 78 | + production_is_positive: bool = False, |
| 79 | +) -> pd.Series: |
| 80 | + """Calculate the portion of excess production stored in the battery. |
| 81 | +
|
| 82 | + Compares available production surplus with the battery's charging capability |
| 83 | + at each timestamp and takes the elementwise minimum. |
| 84 | +
|
| 85 | + Args: |
| 86 | + production: Series of production values (e.g., kW or MW). |
| 87 | + consumption: Series of consumption values (same units as `production`). |
| 88 | + battery: Series representing the battery's available charging capacity |
| 89 | + or power limit at each timestamp. |
| 90 | + production_is_positive: Whether production values are already positive. |
| 91 | + If False, `production` is inverted before clipping. |
| 92 | +
|
| 93 | + Returns: |
| 94 | + A Series showing the actual production power stored in the battery. |
| 95 | + """ |
| 96 | + production_excess_series = production_excess( |
| 97 | + production, consumption, production_is_positive=production_is_positive |
| 98 | + ) |
| 99 | + battery = battery.astype("float64").clip(lower=0) |
| 100 | + return pd.concat([production_excess_series, battery], axis=1).min(axis=1) |
| 101 | + |
| 102 | + |
| 103 | +def grid_feed_in( |
| 104 | + production: pd.Series, |
| 105 | + consumption: pd.Series, |
| 106 | + battery: pd.Series, |
| 107 | + production_is_positive: bool = False, |
| 108 | +) -> pd.Series: |
| 109 | + """Calculate the portion of excess production fed into the grid. |
| 110 | +
|
| 111 | + Subtracts the amount of excess energy stored in the battery from the total |
| 112 | + production surplus to determine how much is exported to the grid. |
| 113 | +
|
| 114 | + Args: |
| 115 | + production: Series of production values (e.g., kW or MW). |
| 116 | + consumption: Series of consumption values (same units as `production`). |
| 117 | + battery: Series representing the battery's available |
| 118 | + charging capacity. |
| 119 | + production_is_positive: Whether production values are already positive. If False, |
| 120 | + `production` is inverted before clipping. |
| 121 | +
|
| 122 | + Returns: |
| 123 | + A Series representing power or energy fed into the grid (≥ 0). |
| 124 | + """ |
| 125 | + production_excess_series = production_excess( |
| 126 | + production, consumption, production_is_positive=production_is_positive |
| 127 | + ) |
| 128 | + battery_series = production_excess_in_bat( |
| 129 | + production, consumption, battery, production_is_positive=production_is_positive |
| 130 | + ) |
| 131 | + return (production_excess_series - battery_series).clip(lower=0) |
| 132 | + |
| 133 | + |
| 134 | +def production_self_consumption( |
| 135 | + production: pd.Series, consumption: pd.Series, production_is_positive: bool = False |
| 136 | +) -> pd.Series: |
| 137 | + """Compute the portion of production directly self-consumed. |
| 138 | +
|
| 139 | + Calculates the part of total production that is used locally rather than |
| 140 | + stored or exported, by subtracting excess production from total production. |
| 141 | +
|
| 142 | + Args: |
| 143 | + production: Series of production values (e.g., kW or MW). |
| 144 | + consumption: Series of consumption values (same units as `production`). |
| 145 | + production_is_positive: Whether production values are already positive. |
| 146 | + If False, `production` is inverted before clipping. |
| 147 | +
|
| 148 | + Returns: |
| 149 | + A Series representing self-consumed production. |
| 150 | +
|
| 151 | + Warns: |
| 152 | + UserWarning: If negative self-consumption values are detected, indicating |
| 153 | + that the computed excess exceeds total production for some entries. |
| 154 | + """ |
| 155 | + asset_production_series = asset_production( |
| 156 | + production, production_is_positive=production_is_positive |
| 157 | + ) |
| 158 | + production_excess_series = production_excess( |
| 159 | + production, consumption, production_is_positive=production_is_positive |
| 160 | + ) |
| 161 | + result = asset_production_series - production_excess_series |
| 162 | + |
| 163 | + if (result < 0).any(): |
| 164 | + warnings.warn( |
| 165 | + "Negative self-consumption values detected. " |
| 166 | + "This indicates production excess exceeds total production for some entries.", |
| 167 | + UserWarning, |
| 168 | + stacklevel=2, |
| 169 | + ) |
| 170 | + |
| 171 | + return result |
| 172 | + |
| 173 | + |
| 174 | +def production_self_share( |
| 175 | + production: pd.Series, consumption: pd.Series, production_is_positive: bool = False |
| 176 | +) -> pd.Series: |
| 177 | + """Calculate the self-consumption share of total consumption. |
| 178 | +
|
| 179 | + Computes the ratio of self-used production to total consumption, |
| 180 | + representing how much of the consumed energy was covered by own production. |
| 181 | +
|
| 182 | + Args: |
| 183 | + production: Series of production values (e.g., kW or MW). |
| 184 | + consumption: Series of consumption values (same units as `production`). |
| 185 | + production_is_positive: Whether production values are already positive. |
| 186 | + If False, `production` is inverted before clipping. |
| 187 | +
|
| 188 | + Returns: |
| 189 | + A Series expressing the self-consumption share (values between 0 and 1). |
| 190 | + Returns NaN where consumption is zero. |
| 191 | + """ |
| 192 | + production_self_use = production_self_consumption( |
| 193 | + production, consumption, production_is_positive=production_is_positive |
| 194 | + ) |
| 195 | + denom = consumption.astype("float64") |
| 196 | + denom = denom.mask(denom <= 0) # NaN when consumption <= 0 |
| 197 | + share = production_self_use.astype("float64") / denom |
| 198 | + return share |
| 199 | + |
| 200 | + |
| 201 | +def consumption( |
| 202 | + df: pd.DataFrame, production_cols: list[str] | None, grid_cols: list[str] |
| 203 | +) -> pd.Series: |
| 204 | + """Infer the consumption column from grid and production data if missing. |
| 205 | +
|
| 206 | + If a 'consumption' column is not present, it is computed as the total grid import |
| 207 | + (sum of all grid columns) minus total production. Safely handles missing or |
| 208 | + empty production columns by treating them as zero. |
| 209 | +
|
| 210 | + Args: |
| 211 | + df: Input DataFrame containing grid and optional production columns. |
| 212 | + production_cols: List of production column names (e.g., "pv", "chp", "battery" or "ev"). |
| 213 | + Can be None or empty if no on-site generation is present. |
| 214 | + grid_cols: List of one or more grid column names. |
| 215 | +
|
| 216 | + Returns: |
| 217 | + A Series representing inferred total consumption, named `"consumption"`. |
| 218 | +
|
| 219 | + Raises: |
| 220 | + ValueError: If `grid_cols` is empty. |
| 221 | + """ |
| 222 | + if "consumption" in df.columns: |
| 223 | + return df["consumption"] |
| 224 | + |
| 225 | + if not grid_cols: |
| 226 | + raise ValueError("At least one grid column must be specified in grid_cols.") |
| 227 | + |
| 228 | + # Compute total grid import and total production |
| 229 | + grid_total = df[grid_cols].sum(axis=1) |
| 230 | + |
| 231 | + # Handle empty production columns safely |
| 232 | + if production_cols: |
| 233 | + production_total = df[production_cols].sum(axis=1) |
| 234 | + else: |
| 235 | + # No production → production_total = 0 |
| 236 | + production_total = pd.Series(0, index=df.index) |
| 237 | + |
| 238 | + # Compute inferred consumption (Series) |
| 239 | + consumption = grid_total - production_total |
| 240 | + consumption.name = "consumption" |
| 241 | + |
| 242 | + return consumption |
0 commit comments