Skip to content

Commit

Permalink
Merge pull request #204 from rl-institut/feature/extended_prices
Browse files Browse the repository at this point in the history
separate prices
  • Loading branch information
j-brendel authored Nov 1, 2024
2 parents da1bb2c + f7ff9ea commit 26036b0
Show file tree
Hide file tree
Showing 5 changed files with 134 additions and 31 deletions.
10 changes: 6 additions & 4 deletions data/examples/electrified_stations.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@
"factor": 2 // factor to multiply column values, eg 0.001 for conversion from W to kW
},
"price_csv": { // optional: price timeseries for this station
"csv_file": "data/examples/price_timeseries.csv", // path to price csv
"csv_file": "data/examples/price_timeseries_extended.csv", // path to price csv
"start_time": "2022-03-07 00:00:00", // optional: start time as YYYY-MM-DD hh:mm:ss. If not given, use CSV timestamps (first column)
"step_duration_s": 3600, // timestep in seconds, required if start_time is given
"column": "price", // optional: column name in .csv. Defaults to last column
"factor": 0.01 // optional: factor to multiply column values, default 1. Simulation expects €/kWh
"step_duration_s": 21600, // timestep in seconds, required if start_time is given
"procurement_column": "procurement", // optional: procurement column name in .csv
"commodity_column": "commodity", // optional: commodity column name in .csv
"virtual_column": "virtual", // optional: column name for virtual costs in .csv
"factor": 0.5 // optional: factor to multiply column values, default 1. Simulation expects €/kWh
},
"battery": { // optional: local stationary battery
"charging_curve": [[0,50], [1,50]], // piecewise linear function that maps SoC to power, from 0 to 1, required
Expand Down
11 changes: 11 additions & 0 deletions data/examples/price_timeseries_extended.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
time,procurement,commodity,virtual
2022-03-07 00:00,0.2,0.1,0.0
2022-03-07 06:00,0.4,0.2,0.1
2022-03-07 12:00,0.3,0.1,0.0
2022-03-07 18:00,0.2,0.3,0.0
2022-03-08 00:00,0.1,0.1,0.0
2022-03-08 06:00,0.4,0.2,0.1
2022-03-08 12:00,0.3,0.1,0.0
2022-03-08 18:00,0.5,0.2,0.0
2022-03-09 00:00,0.1,0.1,0.0
2022-03-09 00:06,0.3,0.4,0.0
9 changes: 6 additions & 3 deletions simba/costs.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,8 @@ def set_charging_infrastructure_costs(self):
self.costs_per_gc[gcID]["c_maint_feed_in_annual"])

# calculate (ceil) number of days in scenario
drive_days = -(-(self.schedule.scenario["scenario"]["n_intervals"] *
self.schedule.scenario["scenario"]["interval"]) // (24 * 60))
scenario_duration = self.scenario.stop_time - self.scenario.start_time
drive_days = -(-scenario_duration.total_seconds() // (60*60*24))
for rot in self.gc_rotations[gcID]:
v_type_rot = f"{rot.vehicle_type}_{rot.charging_type}"
try:
Expand Down Expand Up @@ -322,6 +322,9 @@ def set_electricity_costs(self):
if pv.parent == gcID])
timeseries = vars(self.scenario).get(f"{gcID}_timeseries")

# use procurement and commodity costs read from CSV instead of SpiceEV prices, if exist
prices = station.get("prices", timeseries.get("price [EUR/kWh]"))

# Get the calculation method from args.
cost_calculation_name = "cost_calculation_method_" + station.get("type")
cost_calculation_method = vars(self.args).get(cost_calculation_name)
Expand All @@ -343,7 +346,7 @@ def set_electricity_costs(self):
interval=self.scenario.interval,
timestamps_list=timeseries.get("time"),
power_grid_supply_list=timeseries.get("grid supply [kW]"),
price_list=timeseries.get("price [EUR/kWh]"),
price_list=prices,
power_fix_load_list=timeseries.get("fixed load [kW]"),
power_generation_feed_in_list=timeseries.get("generation feed-in [kW]"),
power_v2g_feed_in_list=timeseries.get("V2G feed-in [kW]"),
Expand Down
115 changes: 101 additions & 14 deletions simba/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -1102,11 +1102,14 @@ def generate_scenario(self, args):
gc_name, start_simulation, stop_simulation)
else:
# read prices from CSV, convert to events
prices = get_price_list_from_csv(price_csv)
prices = get_price_list_from_csv(price_csv, gc_name)
util.save_input_file(price_csv["csv_file"], args)
events["grid_operator_signals"] += generate_event_list_from_prices(
prices, gc_name, start_simulation, stop_simulation,
price_csv.get('start_time'), price_csv.get('step_duration_s'))
# attach expanded price lists to station for report
self.stations[gc_name]["prices"] = generate_price_timeseries(
prices, start_simulation, stop_simulation, interval)

# reformat vehicle types for SpiceEV
vehicle_types_spiceev = {
Expand All @@ -1120,7 +1123,7 @@ def generate_scenario(self, args):
self.scenario = {
"scenario": {
"start_time": start_simulation.isoformat(),
"interval": interval.days * 24 * 60 + interval.seconds // 60,
"interval": interval.total_seconds() // 60,
"n_intervals": ceil((stop_simulation-start_simulation) / interval)
},
"components": {
Expand Down Expand Up @@ -1284,31 +1287,59 @@ def generate_random_price_list(gc_name, start_simulation, stop_simulation):
return events


def get_price_list_from_csv(price_csv_dict):
def get_price_list_from_csv(price_csv_dict, gc_name=None):
""" Read out price CSV.
:param price_csv_dict: price CSV info
:type price_csv_dict: dict
:return: price timestamp (if in first column of CSV) and values
:rtype: list of tuples
:param gc_name: grid connector ID (optional, only relevant for warning)
:type gc_name: string
:return: price timestamp (if in first column of CSV) and tuple of values
:rtype: list
"""
csv_path = Path(price_csv_dict["csv_file"])
if not csv_path.exists():
logging.error(f"price csv file {csv_path} does not exist, skipping price generation")
return []

prices = []
column = price_csv_dict.get("column")
column = price_csv_dict.get("column") # backwards compatibility, deprecated
procurement_column = price_csv_dict.get("procurement_column")
commodity_column = price_csv_dict.get("commodity_column")
virtual_column = price_csv_dict.get("virtual_column")
factor = price_csv_dict.get("factor", 1)
# general checks of given columns
if (procurement_column is None) ^ (commodity_column is None):
logging.warning(f'Price CSV for {gc_name}: only one of '
'procurement_column and commodity_column was given.')
if column is not None:
logging.warning(f'Price CSV for {gc_name}: "column" is deprecated, '
'use procurement_column and commodity_column instead.')
if commodity_column is None:
commodity_column = column
# backup: if neither procurement_column nor commodity_column was given,
# use values from last column in CSV as commodity prices
use_last_column = procurement_column is None and commodity_column is None

with csv_path.open('r', newline='', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile, delimiter=',', quotechar='"')
for idx, row in enumerate(reader):
row_values = list(row.values())
# read value from given column or last column
value_str = row[column] if column else row_values[-1]
value = float(value_str) * factor
# add entry: first cell (may contain datetime), value
prices.append((row_values[0], value))
# read values from given columns or last column
procurement = None
commodity = None
virtual = None
if use_last_column:
commodity = float(row_values[-1]) * factor
else:
if procurement_column is not None:
procurement = float(row[procurement_column]) * factor
if commodity_column is not None:
commodity = float(row[commodity_column]) * factor
if virtual_column is not None:
virtual = float(row[virtual_column]) * factor
# add entry: first cell (may contain datetime), price values
prices.append([row_values[0], (procurement, commodity, virtual)])
return prices


Expand All @@ -1317,7 +1348,7 @@ def generate_event_list_from_prices(
start_events=None, price_interval_s=None):
""" Generate grid operator signals from price list.
:param prices: price timestamp and values
:param prices: price timestamp and values (ts, (proc, comm, virtual cost))
:type prices: list of tuples
:param gc_name: grid connector ID
:type gc_name: string
Expand Down Expand Up @@ -1355,6 +1386,10 @@ def generate_event_list_from_prices(
else:
# compute event timestamp from given arguments
event_time = idx * price_interval + start_events
# update time info in internal list
# first item _may_ have contained iso-timestring,
# but is now guaranteed to contain correct datetime of price change
price[0] = event_time
if stop is None or stop < event_time:
# keep track of last event time
stop = event_time
Expand All @@ -1366,12 +1401,13 @@ def generate_event_list_from_prices(
events.append(event)
# price events known one day in advance
signal_time = event_time - day
# read value from given column or last column
# SpiceEV price: sum of procurement, commodity and virtual costs (some may be None)
sum_price = sum([p or 0 for p in price[1]])
event = {
"start_time": event_time.isoformat(),
"signal_time": signal_time.isoformat(),
"grid_connector_id": gc_name,
"cost": {"type": "fixed", "value": price[1]},
"cost": {"type": "fixed", "value": sum_price},
}
if event_time >= start_simulation:
# event within scenario time: add to list
Expand All @@ -1388,6 +1424,57 @@ def generate_event_list_from_prices(
return events


def generate_price_timeseries(prices, start_simulation, stop_simulation, interval):
"""
Generate price timeseries from event-like price list.
Converts sparse price change events to a timeseries
with entries for each timestep of the simulation.
Each entry contains a dictionary with "procurement" and "commodity" costs.
Virtual costs are ignored.
If the input timeseries covers only part of the simulation, the last price is used to fill up.
This is needed to retain information about specific costs for cost calculation in reports.
:param prices: event-like input price series, containing (ts, (procurement, commodity))
:type prices: list
:param start_simulation: start of simulation time
:type start_simulation: datetime.datetime
:param stop_simulation: end of simulation time
:type stop_simulation: datetime.datetime
:param interval: length of timestep
:type interval: datetime.timedelta
:return: procurement and commodity price lists
:rtype: dict
"""
price_lists = {"procurement": None, "commodity": None}
if len(prices) == 0:
return price_lists
current_time = start_simulation
price = prices.pop(0)
if price[1][0] is not None:
price_lists["procurement"] = list()
if price[1][1] is not None:
price_lists["commodity"] = list()
while current_time < stop_simulation:
# not end of simulation: fill up with price
try:
# get next price step to know when to stop
next_price = prices.pop(0)
stop_time = next_price[0]
except IndexError:
# may fail if price list is not long enough, fill up with last known price
next_price = None
stop_time = stop_simulation
while current_time < stop_time:
# price remains constant until next change
if price[1][0] is not None:
price_lists["procurement"].append(price[1][0])
if price[1][1] is not None:
price_lists["commodity"].append(price[1][1])
current_time += interval
price = next_price
return price_lists


def get_charge_delta_soc(charge_curves: dict, vt: str, ct: str, max_power: float,
duration_min: float, start_soc: float) -> float:
""" Get the delta soc of a charge event for a given vehicle and charge type
Expand Down
20 changes: 10 additions & 10 deletions tests/test_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,7 @@ def test_get_price_list_from_csv(self, tmp_path):
# just file given
prices = schedule.get_price_list_from_csv({'csv_file': tmp_path / 'price.csv'})
assert len(prices) == 3
assert prices[0] == ('2022-03-07 00:00:00', 1)
assert prices[0] == ['2022-03-07 00:00:00', (None, 1.0, None)]

# change column
with pytest.raises(KeyError):
Expand All @@ -705,27 +705,27 @@ def test_get_price_list_from_csv(self, tmp_path):
'csv_file': tmp_path / 'price.csv',
'factor': 1.5
})
assert prices[0][1] == 1.5
assert prices[0][1] == (None, 1.5, None)

def test_generate_event_list_from_prices(self):
prices = [
('2022-03-07 00:00:00', 1),
('2022-03-07 01:00:00', 2),
('2022-03-07 02:00:00', 3)]
['2022-03-07 00:00:00', (1,)],
['2022-03-07 01:00:00', (2,)],
['2022-03-07 02:00:00', (3,)]]
start = datetime.fromisoformat('2022-03-07')
stop = datetime.fromisoformat('2022-03-08')

# no prices: no events
assert len(schedule.generate_event_list_from_prices([], 'GC', start, stop)) == 0

# basic functionality: all events
events = schedule.generate_event_list_from_prices(prices, 'GC', start, stop)
events = schedule.generate_event_list_from_prices(deepcopy(prices), 'GC', start, stop)
assert len(events) == 3

# change list start time
# in-between: all events, different time
events = schedule.generate_event_list_from_prices(
prices, 'GC', start, stop,
deepcopy(prices), 'GC', start, stop,
start_events='2022-03-07 01:30:00',
price_interval_s=60
)
Expand All @@ -734,19 +734,19 @@ def test_generate_event_list_from_prices(self):

# before: only last event
events = schedule.generate_event_list_from_prices(
prices, 'GC', start, stop,
deepcopy(prices), 'GC', start, stop,
start_events='1970-01-01',
price_interval_s=3600
)
assert len(events) == 1 and events[0]["cost"]["value"] == 3
# change start date after price list: only last event
start = datetime.fromisoformat('2022-03-07 12:00:00')
events = schedule.generate_event_list_from_prices(prices, 'GC', start, stop)
events = schedule.generate_event_list_from_prices(deepcopy(prices), 'GC', start, stop)
assert len(events) == 1 and events[0]["cost"]["value"] == 3

# after: no events
events = schedule.generate_event_list_from_prices(
prices, 'GC', start, stop,
deepcopy(prices), 'GC', start, stop,
start_events='3000-01-01',
price_interval_s=3600
)
Expand Down

0 comments on commit 26036b0

Please sign in to comment.