Skip to content

Commit 2aedd35

Browse files
committed
New class SolarWebExportProcessor to create load profiles from a 5min Solar Web data export
1 parent 4964df0 commit 2aedd35

1 file changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
#%%
2+
import logging
3+
4+
logger = logging.getLogger("__main__")
5+
logger.info('[SolarWeb Export Processor] loading module')
6+
7+
import pandas as pd
8+
import numpy as np
9+
import pytz
10+
import os
11+
12+
class SolarWebExportProcessor:
13+
"""
14+
A class to process Fronius Solar Web Export data into a batcontrol load profile.
15+
16+
Source File:
17+
Excel file containing at a minimum the SolarWeb detailed (i.e. 5 minute resolution) export of:
18+
- "Energie Bilanz / Verbrauch" ergo consumption
19+
20+
Additionally, the following columns can be included:
21+
- "Wattpilot / Energie Wattpilot" ergo consumption from Fronius Wattpilot
22+
23+
If these additional columns are included then the load from these "smart" consumers will be subtracted from the
24+
load to get a "base load" under the assumption that these will only run in the cheapest hours anyway.
25+
26+
The load profile will output month, weekday, hour and energy in Wh
27+
28+
Any gaps in the timeseries will be filled with the weekday average across the existing dataset unless
29+
fill_empty_with_average is set to False.
30+
31+
Key Features:
32+
- Loads data from a SolarWeb exported Excel file.
33+
- Processes Wattpilot columns to calculate wallbox load.
34+
- Subtracts wallbox loads to get a base load and optionally smooths the ramp ups and downs.
35+
- Resamples data to hourly intervals.
36+
- Aggregates hourly data to month, weekday, hour as needed for load profile.
37+
- Exports processed data to a CSV file.
38+
39+
Attributes:
40+
file_path (str): Path to the input Excel file.
41+
output_path (str): Path to save the output CSV file.
42+
timezone (str): Timezone for the data (default: 'Europe/Berlin').
43+
fill_empty_with_average (bool): Whether to fill missing data with averages (default: True).
44+
smooth_base_load (bool): Whether to smooth the wallbox ramps in the calculated base load (default: True).
45+
smoothing_threshold (int): Threshold for detecting switched on/off EV wallbox loads (default: 1200 Watts).
46+
smoothing_window_size (int): Window size for smoothing around EV charging (default: 2).
47+
resample_freq (str): Frequency for resampling data (default: '60min').
48+
df (pd.DataFrame): The main DataFrame holding the processed data.
49+
"""
50+
51+
def __init__(self, file_path, output_path='../config/generated_load_profile.csv', timezone='Europe/Berlin',
52+
fill_empty_with_average=True, smooth_base_load=True, smoothing_threshold=1200,
53+
smoothing_window_size=2, resample_freq='60min'):
54+
"""
55+
Initialize the SolarWebExportProcessor.
56+
57+
:param file_path: Path to the Excel file containing the data.
58+
:param output_path: Path to save the output CSV file (default: '../config/generated_load_profile.csv').
59+
:param timezone: Timezone for the data (default: 'Europe/Berlin').
60+
:param fill_empty_with_average: Whether to fill missing data with averages (default: True).
61+
:param smooth_base_load: Whether to smooth the base load (default: True).
62+
:param smoothing_threshold: Threshold for detecting sudden changes in base load (default: 1200 Watts).
63+
:param smoothing_window_size: Window size for smoothing around sudden changes (default: 2).
64+
:param resample_freq: Frequency for resampling data (default: '60min').
65+
"""
66+
self.file_path = file_path
67+
self.output_path = output_path
68+
self.timezone = pytz.timezone(timezone)
69+
self.fill_empty_with_average = fill_empty_with_average
70+
self.smooth_base_load = smooth_base_load
71+
self.smoothing_threshold = smoothing_threshold
72+
self.smoothing_window_size = smoothing_window_size
73+
self.resample_freq = resample_freq
74+
self.df = None
75+
76+
def load_data(self):
77+
"""Load data from the Excel file and preprocess it."""
78+
# Check if the input file exists
79+
if not os.path.exists(self.file_path):
80+
raise FileNotFoundError(f"The input file '{self.file_path}' does not exist.")
81+
82+
# Read excel into pandas dataframe
83+
self.df = pd.read_excel(self.file_path, header=[0, 1], index_col=0, parse_dates=True,
84+
date_format='%d.%m.%Y %H:%M')
85+
86+
# Check if the data has at least 1-hour resolution
87+
time_diff = self.df.index.to_series().diff().min()
88+
if time_diff > pd.Timedelta(hours=1):
89+
raise ValueError(f"The data resolution is larger than 1 hour. Minimum time difference found: {time_diff}.")
90+
91+
# Convert float64 columns to float32 for file/memory size
92+
float64_cols = self.df.select_dtypes(include='float64').columns
93+
self.df[float64_cols] = self.df[float64_cols].astype('float32')
94+
95+
def process_wattpilot_columns(self):
96+
"""Process Wattpilot columns to calculate Load_Wallbox."""
97+
# Step 1: Identify columns containing "Energie Wattpilot"
98+
level_0_columns = self.df.columns.get_level_values(0)
99+
wattpilot_columns = level_0_columns[level_0_columns.str.contains('Energie Wattpilot')]
100+
101+
# Step 2: Check if any matching columns exist
102+
if not wattpilot_columns.empty:
103+
# Create a new column "Load_Wallbox" with the sum of these columns along axis=1 (across rows)
104+
self.df[('Load_Wallbox', '[Wh]')] = self.df[wattpilot_columns].sum(axis=1) # this also replaces all NaN with 0
105+
else:
106+
# If no matching columns exist, create a "Load_Wallbox" column with zeros
107+
self.df[('Load_Wallbox', '[Wh]')] = 0
108+
109+
def calculate_base_load(self):
110+
"""Calculate base load and optionally smooth it."""
111+
112+
# Check if the required column ('Verbrauch', '[Wh]') exists
113+
if ('Verbrauch', '[Wh]') not in self.df.columns:
114+
raise KeyError(f"The required column ('Verbrauch', '[Wh]') does not exist in the input data.")
115+
116+
# Calculate a base load by removing the wallbox loads
117+
self.df[('base_load', '[Wh]')] = self.df['Verbrauch', '[Wh]'] - self.df['Load_Wallbox', '[Wh]']
118+
119+
# Smoothing of data where Wallbox starts or ends charging due to artifacts (if enabled)
120+
if self.smooth_base_load:
121+
# Step 1: Calculate the difference between consecutive values
122+
self.df[('WB_diff', '[Wh]')] = self.df['Load_Wallbox', '[Wh]'].diff().abs()
123+
124+
# Step 2: Define a threshold for detecting sudden changes (e.g., a large jump)
125+
sudden_change_idx = self.df[self.df[('WB_diff', '[Wh]')] > self.smoothing_threshold / 12].index # We're at 5 min intervals thus / 12
126+
127+
# Step 3: Create a new smoothed base load curve
128+
self.df[('base_load_smoothed', '[Wh]')] = self.df[('base_load', '[Wh]')]
129+
130+
# Smooth only around the points with sudden changes (e.g., within a window of +/- smoothing_window_size)
131+
for idx in sudden_change_idx:
132+
int_idx = self.df.index.get_loc(idx)
133+
# Get the window around the sudden change index (ensuring we can't go out of bounds)
134+
start_idx = max(1, int_idx - self.smoothing_window_size)
135+
end_idx = min(len(self.df) - 1, int_idx + self.smoothing_window_size)
136+
137+
# Calculate averages before and after ramp
138+
avg_before = self.df[('base_load_smoothed', '[Wh]')].iloc[start_idx - 1:int_idx - 1].mean()
139+
avg_after = self.df[('base_load_smoothed', '[Wh]')].iloc[int_idx + 1:end_idx + 1].mean()
140+
141+
# Use averages to replace at detected ramps
142+
self.df[('base_load_smoothed', '[Wh]')].iat[int_idx - 1] = avg_before # for ramp downs
143+
self.df[('base_load_smoothed', '[Wh]')].iat[int_idx] = avg_after # for ramp ups
144+
else:
145+
# If smoothing is disabled, use the unsmoothed base load
146+
self.df[('base_load_smoothed', '[Wh]')] = self.df[('base_load', '[Wh]')]
147+
148+
def resample_and_add_temporal_columns(self):
149+
"""Resample data to hourly intervals and add temporal columns."""
150+
# Resampling to hourly data
151+
def custom_agg(column):
152+
if column.name[1] == '[Wh]': # Check the second level of the column header
153+
return column.sum() # Apply sum to 'Wh'
154+
else:
155+
result = column.mean() # Apply mean to all others
156+
return np.float32(result) # Convert back to float32
157+
158+
# Resample dataframe to hourly data
159+
self.df = self.df.resample(self.resample_freq).apply(custom_agg)
160+
161+
# Drop column multi index
162+
self.df.columns = self.df.columns.droplevel(1)
163+
164+
# Add month, weekday, and hour columns
165+
self.df['month'] = self.df.index.month
166+
self.df['weekday'] = self.df.index.weekday # Monday=0, Sunday=6
167+
self.df['hour'] = self.df.index.hour
168+
169+
def process_and_export_data(self):
170+
"""Process data and export to CSV."""
171+
172+
# Define aggregation function
173+
def calculate_energy(group):
174+
"""Calculate confidence intervals for a group."""
175+
mean = group.mean()
176+
return pd.Series({
177+
'energy': mean,
178+
})
179+
180+
# Group by month, weekday, and hour, and calculate the mean energy consumption
181+
grouped = self.df.groupby(['month', 'weekday', 'hour'])['base_load_smoothed'].apply(calculate_energy).unstack()
182+
183+
# Check if the grouped result is missing rows
184+
expected_rows = 12 * 7 * 24 # 12 months, 7 weekdays, 24 hours
185+
if len(grouped) < expected_rows and self.fill_empty_with_average:
186+
print("Data is missing rows. Filling missing values with averages...")
187+
188+
# Create a complete multi-index for all combinations of month, weekday, and hour
189+
full_index = pd.MultiIndex.from_product(
190+
[range(1, 13), range(7), range(24)], # All months, weekdays, and hours
191+
names=['month', 'weekday', 'hour']
192+
)
193+
194+
# Reindex the grouped result to include all combinations
195+
grouped_full = grouped.reindex(full_index)
196+
197+
# Calculate the average for each weekday and hour
198+
weekday_hour_avg = grouped_full.groupby(['weekday', 'hour']).mean()
199+
200+
# Fill missing values in the grouped result with the weekday and hour average
201+
for (weekday, hour), avg_value in weekday_hour_avg.iterrows():
202+
grouped_full.loc[(slice(None), weekday, hour), :] = grouped_full.loc[
203+
(slice(None), weekday, hour), :
204+
].fillna(avg_value)
205+
206+
# Reset the index for better CSV formatting (optional)
207+
grouped_filled = grouped_full.reset_index()
208+
209+
# Write the result to a CSV file
210+
grouped_filled.to_csv(self.output_path, index=False)
211+
print(f"Missing values filled and saved to '{self.output_path}'.")
212+
else:
213+
print("Data is complete. No missing rows to fill.")
214+
# Export the original grouped data to CSV
215+
grouped.reset_index().to_csv(self.output_path, index=False)
216+
print(f"Data saved to '{self.output_path}'.")
217+
218+
def run(self):
219+
"""Run the entire processing pipeline."""
220+
try:
221+
self.load_data()
222+
self.process_wattpilot_columns()
223+
self.calculate_base_load()
224+
self.resample_and_add_temporal_columns()
225+
self.process_and_export_data()
226+
except Exception as e:
227+
print(f"An error occurred: {e}")
228+
229+
# Example usage
230+
if __name__ == "__main__":
231+
# Initialize the processor with file path, timezone, and smoothing options
232+
processor = SolarWebExportProcessor(
233+
file_path='../config/SolarWebExport.xlsx',
234+
output_path='../config/generated_load_profile.csv',
235+
timezone='Europe/Berlin',
236+
fill_empty_with_average=True,
237+
smooth_base_load=True, # Enable smoothing
238+
smoothing_threshold=1200, # Set smoothing threshold in Watts
239+
smoothing_window_size=2, # Set smoothing window size
240+
resample_freq='60min'
241+
)
242+
processor.run()

0 commit comments

Comments
 (0)