@@ -89,21 +89,23 @@ class CsvForecaster(Forecaster):
89
89
"""
90
90
This class represents a forecaster that provides timeseries for forecasts derived from existing files.
91
91
92
- It initializes with the provided index. It includes methods to retrieve forecasts for specific columns,
93
- availability of units, and prices of fuel types, returning the corresponding timeseries as pandas Series.
92
+ It initializes with the provided index and configuration data, including power plants, demand units,
93
+ and market configurations. The forecaster also supports optional inputs like DSM (demand-side management) units,
94
+ buses, and lines for more advanced forecasting scenarios.
94
95
95
- Attributes:
96
- index (pandas.Series): The index of the forecasts.
97
- powerplants_units (dict[str, pandas.Series]): The power plants.
96
+ Methods are included to retrieve forecasts for specific columns, availability of units,
97
+ and prices of fuel types, returning the corresponding timeseries as pandas Series.
98
98
99
99
Args:
100
- index (pandas.Series): The index of the forecasts.
101
- powerplants_units (dict[str, pandas.Series]): The power plants.
102
-
103
- Example:
104
- >>> forecaster = CsvForecaster(index=pd.Series([1, 2, 3]))
105
- >>> forecast = forecaster['temperature']
106
- >>> print(forecast)
100
+ index (pd.Series): The index of the forecasts.
101
+ powerplants_units (pd.DataFrame): A DataFrame containing information about power plants.
102
+ demand_units (pd.DataFrame): A DataFrame with demand unit data.
103
+ market_configs (dict[str, dict]): Configuration details for the markets.
104
+ buses (pd.DataFrame | None, optional): A DataFrame of buses information. Defaults to None.
105
+ lines (pd.DataFrame | None, optional): A DataFrame of line information. Defaults to None.
106
+ save_path (str, optional): Path where the forecasts should be saved. Defaults to an empty string.
107
+ *args (object): Additional positional arguments.
108
+ **kwargs (object): Additional keyword arguments.
107
109
108
110
"""
109
111
@@ -112,7 +114,9 @@ def __init__(
112
114
index : pd .Series ,
113
115
powerplants_units : pd .DataFrame ,
114
116
demand_units : pd .DataFrame ,
115
- market_configs : dict = {},
117
+ market_configs : dict [str , dict ],
118
+ buses : pd .DataFrame | None = None ,
119
+ lines : pd .DataFrame | None = None ,
116
120
save_path : str = "" ,
117
121
* args ,
118
122
** kwargs ,
@@ -122,6 +126,9 @@ def __init__(
122
126
self .powerplants_units = powerplants_units
123
127
self .demand_units = demand_units
124
128
self .market_configs = market_configs
129
+ self .buses = buses
130
+ self .lines = lines
131
+
125
132
self .forecasts = pd .DataFrame (index = index )
126
133
self .save_path = save_path
127
134
@@ -191,24 +198,51 @@ def calc_forecast_if_needed(self):
191
198
"""
192
199
Calculates the forecasts if they are not already calculated.
193
200
194
- This method calculates price forecast and residual load forecast for available markets, if
195
- these don't already exist.
201
+ This method calculates price forecast and residual load forecast for available markets,
202
+ and other necessary forecasts if they don't already exist.
196
203
"""
204
+ self .add_missing_availability_columns ()
205
+ self .calculate_market_forecasts ()
206
+
207
+ # the following forecasts are only calculated if buses and lines are available
208
+ # and self.demand_units have a node column
209
+ if self .buses is not None and self .lines is not None :
210
+ # check if the demand_units have a node column and
211
+ # if the nodes are available in the buses
212
+ if (
213
+ "node" in self .demand_units .columns
214
+ and self .demand_units ["node" ].isin (self .buses .index ).all ()
215
+ ):
216
+ self .add_node_congestion_signals ()
217
+ self .add_utilisation_forecasts ()
218
+ else :
219
+ self .logger .warning (
220
+ "Node-specific congestion signals and renewable utilisation forecasts could not be calculated. "
221
+ "Either 'node' column is missing in demand_units or nodes are not available in buses."
222
+ )
197
223
198
- cols = []
199
- for pp in self .powerplants_units .index :
200
- col = f"availability_{ pp } "
201
- if col not in self .forecasts .columns :
202
- s = pd .Series (1 , index = self .forecasts .index )
203
- s .name = col
204
- cols .append (s )
205
- cols .append (self .forecasts )
206
- self .forecasts = pd .concat (cols , axis = 1 ).copy ()
224
+ def add_missing_availability_columns (self ):
225
+ """Add missing availability columns to the forecasts."""
226
+ missing_cols = [
227
+ f"availability_{ pp } "
228
+ for pp in self .powerplants_units .index
229
+ if f"availability_{ pp } " not in self .forecasts .columns
230
+ ]
207
231
232
+ if missing_cols :
233
+ # Create a DataFrame with the missing columns initialized to 1
234
+ missing_data = pd .DataFrame (
235
+ 1 , index = self .forecasts .index , columns = missing_cols
236
+ )
237
+ # Append the missing columns to the forecasts
238
+ self .forecasts = pd .concat ([self .forecasts , missing_data ], axis = 1 ).copy ()
239
+
240
+ def calculate_market_forecasts (self ):
241
+ """Calculate market-specific price and residual load forecasts."""
208
242
for market_id , config in self .market_configs .items ():
209
243
if config ["product_type" ] != "energy" :
210
244
self .logger .warning (
211
- f"Price forecast could be calculated for { market_id } . It can only be calculated for energy only markets for now"
245
+ f"Price forecast could not be calculated for { market_id } . It can only be calculated for energy- only markets for now. "
212
246
)
213
247
continue
214
248
@@ -222,6 +256,29 @@ def calc_forecast_if_needed(self):
222
256
self .calculate_residual_load_forecast (market_id = market_id )
223
257
)
224
258
259
+ def add_node_congestion_signals (self ):
260
+ """Add node-specific congestion signals to the forecasts."""
261
+ node_congestion_signal_df = self .calculate_node_specific_congestion_forecast ()
262
+ for col in node_congestion_signal_df .columns :
263
+ if col not in self .forecasts .columns :
264
+ self .forecasts [col ] = node_congestion_signal_df [col ]
265
+
266
+ def add_utilisation_forecasts (self ):
267
+ """Add renewable utilisation forecasts if missing."""
268
+ utilisation_columns = [
269
+ f"{ node } _renewable_utilisation"
270
+ for node in self .demand_units ["node" ].unique ()
271
+ ]
272
+ utilisation_columns .append ("all_nodes_renewable_utilisation" )
273
+
274
+ if not all (col in self .forecasts .columns for col in utilisation_columns ):
275
+ renewable_utilisation_forecast = (
276
+ self .calculate_renewable_utilisation_forecast ()
277
+ )
278
+ for col in renewable_utilisation_forecast .columns :
279
+ if col not in self .forecasts .columns :
280
+ self .forecasts [col ] = renewable_utilisation_forecast [col ]
281
+
225
282
def get_registered_market_participants (self , market_id ):
226
283
"""
227
284
Retrieves information about market participants to make accurate price forecasts.
@@ -299,7 +356,7 @@ def calculate_market_price_forecast(self, market_id):
299
356
300
357
"""
301
358
302
- # calculate infeed of renewables and residual demand_df
359
+ # calculate infeed of renewables and residual demand
303
360
# check if max_power is a series or a float
304
361
305
362
# select only those power plant units, which have a bidding strategy for the specific market_id
@@ -380,6 +437,131 @@ def calculate_marginal_cost(self, pp_series: pd.Series) -> pd.Series:
380
437
381
438
return marginal_cost
382
439
440
+ def calculate_node_specific_congestion_forecast (self ) -> pd .DataFrame :
441
+ """
442
+ Calculates a collective node-specific congestion signal by aggregating the congestion severity of all
443
+ transmission lines connected to each node, taking into account powerplant load based on availability factors.
444
+
445
+ Returns:
446
+ pd.DataFrame: A DataFrame with columns for each node, where each column represents the collective
447
+ congestion signal time series for that node.
448
+ """
449
+ # Step 1: Calculate powerplant load using availability factors
450
+ availability_factor_df = pd .DataFrame (
451
+ index = self .index , columns = self .powerplants_units .index , data = 0.0
452
+ )
453
+
454
+ # Calculate load for each powerplant based on availability factor and max power
455
+ for pp , max_power in self .powerplants_units ["max_power" ].items ():
456
+ availability_factor_df [pp ] = (
457
+ self .forecasts [f"availability_{ pp } " ] * max_power
458
+ )
459
+
460
+ # Step 2: Calculate net load for each node (demand - generation)
461
+ net_load_by_node = {}
462
+
463
+ for node in self .demand_units ["node" ].unique ():
464
+ # Calculate total demand for this node
465
+ node_demand_units = self .demand_units [
466
+ self .demand_units ["node" ] == node
467
+ ].index
468
+ node_demand = self .forecasts [node_demand_units ].sum (axis = 1 )
469
+
470
+ # Calculate total generation for this node by summing powerplant loads
471
+ node_generation_units = self .powerplants_units [
472
+ self .powerplants_units ["node" ] == node
473
+ ].index
474
+ node_generation = availability_factor_df [node_generation_units ].sum (axis = 1 )
475
+
476
+ # Calculate net load (demand - generation)
477
+ net_load_by_node [node ] = node_demand - node_generation
478
+
479
+ # Step 3: Calculate line-specific congestion severity
480
+ line_congestion_severity = pd .DataFrame (index = self .index )
481
+
482
+ for line_id , line_data in self .lines .iterrows ():
483
+ node1 , node2 = line_data ["bus0" ], line_data ["bus1" ]
484
+ line_capacity = line_data ["s_nom" ]
485
+
486
+ # Calculate net load for the line as the sum of net loads from both connected nodes
487
+ line_net_load = net_load_by_node [node1 ] + net_load_by_node [node2 ]
488
+ congestion_severity = line_net_load / line_capacity
489
+
490
+ # Store the line-specific congestion severity in DataFrame
491
+ line_congestion_severity [f"{ line_id } _congestion_severity" ] = (
492
+ congestion_severity
493
+ )
494
+
495
+ # Step 4: Calculate node-specific congestion signal by aggregating connected lines
496
+ node_congestion_signal = pd .DataFrame (index = self .index )
497
+
498
+ for node in self .demand_units ["node" ].unique ():
499
+ # Find all lines connected to this node
500
+ connected_lines = self .lines [
501
+ (self .lines ["bus0" ] == node ) | (self .lines ["bus1" ] == node )
502
+ ].index
503
+
504
+ # Collect all relevant line congestion severities
505
+ relevant_lines = [
506
+ f"{ line_id } _congestion_severity" for line_id in connected_lines
507
+ ]
508
+
509
+ # Ensure only existing columns are used to avoid KeyError
510
+ relevant_lines = [
511
+ line
512
+ for line in relevant_lines
513
+ if line in line_congestion_severity .columns
514
+ ]
515
+
516
+ # Aggregate congestion severities for this node (use max or mean)
517
+ if relevant_lines :
518
+ node_congestion_signal [f"{ node } _congestion_severity" ] = (
519
+ line_congestion_severity [relevant_lines ].max (axis = 1 )
520
+ )
521
+
522
+ return node_congestion_signal
523
+
524
+ def calculate_renewable_utilisation_forecast (self ) -> pd .DataFrame :
525
+ """
526
+ Calculates the renewable utilisation forecast by summing the available renewable generation
527
+ for each node and an overall 'all_nodes' summary.
528
+
529
+ Returns:
530
+ pd.DataFrame: A DataFrame with columns for each node, where each column represents the renewable
531
+ utilisation signal time series for that node and a column for total utilisation across all nodes.
532
+ """
533
+ # Initialize a DataFrame to store renewable utilisation for each node
534
+ renewable_utilisation = pd .DataFrame (index = self .index )
535
+
536
+ # Identify renewable power plants by filtering `powerplants_units` DataFrame
537
+ renewable_plants = self .powerplants_units [
538
+ self .powerplants_units ["fuel_type" ] == "renewable"
539
+ ]
540
+
541
+ # Calculate utilisation based on availability and max power for each renewable plant
542
+ for node in self .demand_units ["node" ].unique ():
543
+ node_renewable_sum = pd .Series (0 , index = self .index )
544
+
545
+ # Filter renewable plants in this specific node
546
+ node_renewable_plants = renewable_plants [renewable_plants ["node" ] == node ]
547
+
548
+ for pp in node_renewable_plants .index :
549
+ max_power = node_renewable_plants .loc [pp , "max_power" ]
550
+ availability_col = f"availability_{ pp } "
551
+
552
+ # Calculate renewable power based on availability and max capacity
553
+ if availability_col in self .forecasts .columns :
554
+ node_renewable_sum += self .forecasts [availability_col ] * max_power
555
+
556
+ # Store the node-specific renewable utilisation
557
+ renewable_utilisation [f"{ node } _renewable_utilisation" ] = node_renewable_sum
558
+
559
+ # Calculate the total renewable utilisation across all nodes
560
+ all_nodes_sum = renewable_utilisation .sum (axis = 1 )
561
+ renewable_utilisation ["all_nodes_renewable_utilisation" ] = all_nodes_sum
562
+
563
+ return renewable_utilisation
564
+
383
565
def save_forecasts (self , path = None ):
384
566
"""
385
567
Saves the forecasts to a csv file located at the specified path.
0 commit comments