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