Skip to content

Commit 5d127a9

Browse files
feat: add reporting metrics
Signed-off-by: Mohammad Tayyab <[email protected]>
1 parent 8687797 commit 5d127a9

File tree

3 files changed

+404
-0
lines changed

3 files changed

+404
-0
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Metrics calculation for energy reporting."""
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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

Comments
 (0)