Skip to content

Commit e94da55

Browse files
authored
Merge branch 'main' into paper
2 parents 989df95 + ee2294f commit e94da55

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+72609
-1236
lines changed

.readthedocs.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
# Required
1010
version: 2
11+
sphinx:
12+
# Path to your Sphinx configuration file.
13+
configuration: docs/source/conf.py
1114

1215
# Set the version of Python and other tools you might need
1316
build:

assume/common/base.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,8 @@ class SupportsMinMax(BaseUnit):
314314

315315
min_power: float
316316
max_power: float
317-
ramp_down: float
318-
ramp_up: float
317+
ramp_down: float = None
318+
ramp_up: float = None
319319
efficiency: float
320320
emission_factor: float
321321
min_operating_time: int = 0
@@ -355,6 +355,9 @@ def calculate_ramp(
355355
Returns:
356356
float: The corrected possible power to offer according to ramping restrictions.
357357
"""
358+
if self.ramp_down is None and self.ramp_up is None:
359+
return power
360+
358361
# was off before, but should be on now and min_down_time is not reached
359362
if power > 0 and op_time < 0 and op_time > -self.min_down_time:
360363
power = 0
@@ -366,20 +369,23 @@ def calculate_ramp(
366369
# if less than min_power is required, we run min_power
367370
# we could also split at self.min_power/2
368371
return power
372+
369373
# ramp up constraint
370374
# max_power + current_power < previous_power + unit.ramp_up
371-
power = min(
372-
power,
373-
previous_power + self.ramp_up - current_power,
374-
self.max_power - current_power,
375-
)
375+
if self.ramp_up is not None:
376+
power = min(
377+
power,
378+
previous_power + self.ramp_up - current_power,
379+
self.max_power - current_power,
380+
)
376381
# ramp down constraint
377382
# min_power + current_power > previous_power - unit.ramp_down
378-
power = max(
379-
power,
380-
previous_power - self.ramp_down - current_power,
381-
self.min_power - current_power,
382-
)
383+
if self.ramp_down is not None:
384+
power = max(
385+
power,
386+
previous_power - self.ramp_down - current_power,
387+
self.min_power - current_power,
388+
)
383389
return power
384390

385391
def get_operation_time(self, start: datetime) -> int:

assume/common/forecasts.py

Lines changed: 208 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -89,21 +89,23 @@ class CsvForecaster(Forecaster):
8989
"""
9090
This class represents a forecaster that provides timeseries for forecasts derived from existing files.
9191
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.
9495
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.
9898
9999
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.
107109
108110
"""
109111

@@ -112,7 +114,9 @@ def __init__(
112114
index: pd.Series,
113115
powerplants_units: pd.DataFrame,
114116
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,
116120
save_path: str = "",
117121
*args,
118122
**kwargs,
@@ -122,6 +126,9 @@ def __init__(
122126
self.powerplants_units = powerplants_units
123127
self.demand_units = demand_units
124128
self.market_configs = market_configs
129+
self.buses = buses
130+
self.lines = lines
131+
125132
self.forecasts = pd.DataFrame(index=index)
126133
self.save_path = save_path
127134

@@ -191,24 +198,51 @@ def calc_forecast_if_needed(self):
191198
"""
192199
Calculates the forecasts if they are not already calculated.
193200
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.
196203
"""
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+
)
197223

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+
]
207231

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."""
208242
for market_id, config in self.market_configs.items():
209243
if config["product_type"] != "energy":
210244
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."
212246
)
213247
continue
214248

@@ -222,6 +256,29 @@ def calc_forecast_if_needed(self):
222256
self.calculate_residual_load_forecast(market_id=market_id)
223257
)
224258

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+
225282
def get_registered_market_participants(self, market_id):
226283
"""
227284
Retrieves information about market participants to make accurate price forecasts.
@@ -299,7 +356,7 @@ def calculate_market_price_forecast(self, market_id):
299356
300357
"""
301358

302-
# calculate infeed of renewables and residual demand_df
359+
# calculate infeed of renewables and residual demand
303360
# check if max_power is a series or a float
304361

305362
# 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:
380437

381438
return marginal_cost
382439

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+
383565
def save_forecasts(self, path=None):
384566
"""
385567
Saves the forecasts to a csv file located at the specified path.

assume/common/utils.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import inspect
88
import logging
99
import os
10+
import re
1011
import sys
1112
from collections import defaultdict
1213
from datetime import datetime, timedelta, timezone
@@ -26,9 +27,13 @@
2627

2728
freq_map = {
2829
"h": rr.HOURLY,
30+
"hour": rr.HOURLY,
2931
"m": rr.MINUTELY,
32+
"min": rr.MINUTELY,
3033
"d": rr.DAILY,
34+
"day": rr.DAILY,
3135
"w": rr.WEEKLY,
36+
"week": rr.WEEKLY,
3237
}
3338

3439

@@ -448,8 +453,17 @@ def convert_to_rrule_freq(string: str) -> tuple[int, int]:
448453
Returns:
449454
tuple[int, int]: The rrule frequency and interval.
450455
"""
451-
freq = freq_map[string[-1]]
452-
interval = int(string[:-1])
456+
457+
# try to identify the frequency and raise an error if it is not found
458+
try:
459+
freq = freq_map["".join(re.findall(r"\D", string))]
460+
except KeyError:
461+
raise ValueError(
462+
f"Frequency '{string}' not supported. Supported frequencies are {list(freq_map.keys())}"
463+
)
464+
465+
interval = int("".join(re.findall(r"\d", string)))
466+
453467
return freq, interval
454468

455469

0 commit comments

Comments
 (0)