From 5ae1eafd516c83ffd736d04293f258606a516320 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 19 Mar 2024 10:43:58 +0100 Subject: [PATCH 01/68] Split args.vehicle_types into vehicle_types_path and vehicle_types Variables have consistent type and do not mutate their usecase --- data/examples/simba.cfg | 2 +- simba/__main__.py | 2 +- simba/simulate.py | 5 ++--- simba/util.py | 2 +- tests/test_schedule.py | 18 ++++++++++++++++++ tests/test_simulate.py | 4 ++-- tests/test_station_optimization.py | 7 +++---- 7 files changed, 28 insertions(+), 12 deletions(-) diff --git a/data/examples/simba.cfg b/data/examples/simba.cfg index 4a1baaba..342ce64f 100644 --- a/data/examples/simba.cfg +++ b/data/examples/simba.cfg @@ -9,7 +9,7 @@ output_directory = data/output/ # Electrified stations (required) electrified_stations = data/examples/electrified_stations.json # Vehicle types (defaults to: ./data/examples/vehicle_types.json) -vehicle_types = data/examples/vehicle_types.json +vehicle_types_path = data/examples/vehicle_types.json # Path to station data with stations heights # (Optional: needed if mileage in vehicle types not constant and inclination should be considered) station_data_path = data/examples/all_stations.csv diff --git a/simba/__main__.py b/simba/__main__.py index a2db7b98..9863cd3c 100644 --- a/simba/__main__.py +++ b/simba/__main__.py @@ -23,7 +23,7 @@ args.output_directory = None if args.output_directory is not None: # copy input files to output to ensure reproducibility - copy_list = [args.config, args.electrified_stations, args.vehicle_types] + copy_list = [args.config, args.electrified_stations, args.vehicle_types_path] if "station_optimization" in args.mode: copy_list.append(args.optimizer_config) diff --git a/simba/simulate.py b/simba/simulate.py index e4247d1f..460523ce 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -41,11 +41,10 @@ def pre_simulation(args): :rtype: simba.schedule.Schedule """ try: - with open(args.vehicle_types, encoding='utf-8') as f: + with open(args.vehicle_types_path, encoding='utf-8') as f: vehicle_types = util.uncomment_json_file(f) - del args.vehicle_types except FileNotFoundError: - raise Exception(f"Path to vehicle types ({args.vehicle_types}) " + raise Exception(f"Path to vehicle types ({args.vehicle_types_path}) " "does not exist. Exiting...") # load stations file diff --git a/simba/util.py b/simba/util.py index 67729eca..bb401cd5 100644 --- a/simba/util.py +++ b/simba/util.py @@ -251,7 +251,7 @@ def get_args(): parser.add_argument('--output-directory', default="data/sim_outputs", help='Location where all simulation outputs are stored') parser.add_argument('--electrified-stations', help='include electrified_stations json') - parser.add_argument('--vehicle-types', default="data/examples/vehicle_types.json", + parser.add_argument('--vehicle-types-path', default="data/examples/vehicle_types.json", help='location of vehicle type definitions') parser.add_argument('--station_data_path', default=None, help='Use station data to back calculation of consumption with height\ diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 1504a48d..76d770c9 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -23,6 +23,10 @@ } +def basic_run(): + return TestSchedule().basic_run() + + class TestSchedule: temperature_path = example_root / 'default_temp_winter.csv' lol_path = example_root / 'default_level_of_loading_over_day.csv' @@ -33,6 +37,20 @@ class TestSchedule: with open(example_root / "vehicle_types.json", "r", encoding='utf-8') as file: vehicle_types = util.uncomment_json_file(file) + path_to_all_station_data = example_root / "all_stations.csv" + + @pytest.fixture + def default_schedule_arguments(self): + arguments = {"path_to_csv": None, + "vehicle_types": self.vehicle_types, + "stations": self.electrified_stations, + "station_data_path": self.path_to_all_station_data, + "outside_temperature_over_day_path": self.temperature_path, + "level_of_loading_over_day_path": self.lol_path + } + arguments.update(**mandatory_args) + return arguments + def basic_run(self): """Returns a schedule, scenario and args after running SimBA. :return: schedule, scenario, args diff --git a/tests/test_simulate.py b/tests/test_simulate.py index 45a88d5e..83e0eadd 100644 --- a/tests/test_simulate.py +++ b/tests/test_simulate.py @@ -14,7 +14,7 @@ class TestSimulate: # Add propagate_mode_errors as developer setting to raise Exceptions. DEFAULT_VALUES = { - "vehicle_types": example_path / "vehicle_types.json", + "vehicle_types_path": example_path / "vehicle_types.json", "electrified_stations": example_path / "electrified_stations.json", "cost_parameters_file": example_path / "cost_params.json", "outside_temperature_over_day_path": example_path / "default_temp_summer.csv", @@ -61,7 +61,7 @@ def test_missing(self): self.DEFAULT_VALUES["propagate_mode_errors"] = values["propagate_mode_errors"] # required file missing - for file_type in ["vehicle_types", "electrified_stations", "cost_parameters_file"]: + for file_type in ["vehicle_types_path", "electrified_stations", "cost_parameters_file"]: values[file_type] = "" with pytest.raises(Exception): simulate(Namespace(**values)) diff --git a/tests/test_station_optimization.py b/tests/test_station_optimization.py index 20041379..24ed25be 100644 --- a/tests/test_station_optimization.py +++ b/tests/test_station_optimization.py @@ -53,8 +53,8 @@ def setup_test(self, tmp_path): # replace line which defines vehicle_types up to line break. line break is concatenated in # the replacement, to keep format src_text = re.sub( - r"(vehicle_types\s=.*)(:=\r\n|\r|\n)", - "vehicle_types = " + vehicles_dest_str + r"\g<2>", src_text) + r"(vehicle_types_path\s=.*)(:=\r\n|\r|\n)", + "vehicle_types_path = " + vehicles_dest_str + r"\g<2>", src_text) # Use the default electrified stations from example folder but change some values with open(example_root / "electrified_stations.json", "r", encoding='utf-8') as file: @@ -100,7 +100,6 @@ def basic_run(self, trips_file_name="trips.csv"): outside_temperatures=None, level_of_loading_over_day=None) args2 = copy(args) - del args2.vehicle_types generated_schedule = Schedule.from_csv(path_to_trips, self.vehicle_types, self.electrified_stations, **vars(args2)) @@ -195,7 +194,7 @@ def test_deep_optimization(self): trips_file_name = "trips_extended.csv" # adjust mileage so scenario is not possible without adding electrification - self.vehicle_types = adjust_vehicle_file(args.vehicle_types, mileage=2, capacity=150) + self.vehicle_types = adjust_vehicle_file(args.vehicle_types_path, mileage=2, capacity=150) sched, scen, args = self.basic_run(trips_file_name=trips_file_name) # optimization can only be properly tested if negative rotations exist assert len(sched.get_negative_rotations(scen)) > 0 From 40d4e3771b5b443dc1b635a314738583a4bf5d82 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 19 Mar 2024 14:07:14 +0100 Subject: [PATCH 02/68] Refactor Consumption. Add Datacontainer. Add Soc-Dispatcher Fix tests --- simba/consumption.py | 195 +++++++++++++++++++--------- simba/data_container.py | 86 +++++++++++++ simba/ids.py | 7 + simba/optimizer_util.py | 31 +---- simba/report.py | 3 +- simba/rotation.py | 25 ++-- simba/schedule.py | 197 +++++++++++++++++++++++++++-- simba/simulate.py | 60 ++++++--- simba/station_optimization.py | 5 +- tests/helpers.py | 20 ++- tests/test_consumption.py | 12 +- tests/test_schedule.py | 116 +++++++++-------- tests/test_simulate.py | 2 +- tests/test_soc_dispatcher.py | 121 ++++++++++++++++++ tests/test_station_optimization.py | 13 +- 15 files changed, 684 insertions(+), 209 deletions(-) create mode 100644 simba/data_container.py create mode 100644 simba/ids.py create mode 100644 tests/test_soc_dispatcher.py diff --git a/simba/consumption.py b/simba/consumption.py index fb00143a..9f23a994 100644 --- a/simba/consumption.py +++ b/simba/consumption.py @@ -1,6 +1,9 @@ import csv +import warnings + import pandas as pd from simba import util +from simba.ids import INCLINE, LEVEL_OF_LOADING, SPEED, T_AMB, CONSUMPTION, VEHICLE_TYPE class Consumption: @@ -55,9 +58,6 @@ def calculate_consumption(self, time, distance, vehicle_type, charging_type, tem :type mean_speed: float :return: Consumed energy [kWh] and delta SOC as tuple :rtype: (float, float) - - :raises KeyError: if there is missing data for temperature or lol data - :raises AttributeError: if there is no path to temperature or lol data provided """ assert self.vehicle_types.get(vehicle_type, {}).get(charging_type), ( @@ -73,71 +73,152 @@ def calculate_consumption(self, time, distance, vehicle_type, charging_type, tem # if no specific Temperature is given, lookup temperature if temp is None: - try: - temp = self.temperatures_by_hour[time.hour] - except AttributeError as e: - raise AttributeError( - "Neither of these conditions is met:\n" - "1. Temperature data is available for every trip through the trips file " - "or a temperature over day file.\n" - f"2. A constant mileage for the vehicle " - f"{vehicle_info['mileage']} is provided." - ) from e - except KeyError as e: - raise KeyError(f"No temperature data for the hour {time.hour} is provided") from e - - # if no specific LoL is given, lookup temperature + temp = self.get_temperature(time, vehicle_info) + + # if no specific LoL is given, lookup LoL if level_of_loading is None: - try: - level_of_loading = self.lol_by_hour[time.hour] - except AttributeError as e: - raise AttributeError( - "Neither of these conditions is met:\n" - "1. Level of loading data is available for every trip through the trips file " - "or a level of loading over day file.\n" - f"2. A constant mileage for the vehicle " - f"{vehicle_info['mileage']} is provided." - ) from e - except KeyError as e: - raise KeyError(f"No level of loading for the hour {time.hour} is provided") from e + level_of_loading = self.get_level_of_loading(time, vehicle_info) # load consumption csv consumption_path = str(vehicle_info["mileage"]) # consumption_files holds interpol functions of csv files which are called directly - # try to use the interpol function. If it does not exist yet its created in except case. - consumption_function = vehicle_type+"_from_"+consumption_path + consumption_lookup_name = self.get_consumption_lookup_name(consumption_path, vehicle_type) + + # This lookup includes the vehicle type. If the consumption data did not include vehicle + # types this key will not be found. In this case use the file path instead try: - mileage = self.consumption_files[consumption_function]( - this_incline=height_diff / distance, this_temp=temp, - this_lol=level_of_loading, this_speed=mean_speed) + interpol_function = self.consumption_files[consumption_lookup_name] except KeyError: - # creating the interpol function from csv file. - delim = util.get_csv_delim(consumption_path) - df = pd.read_csv(consumption_path, sep=delim) - # create lookup table and make sure its in the same order as the input point - # which will be the input for the nd lookup - df = df[df["vehicle_type"] == vehicle_type] - assert len(df) > 0, f"Vehicle type {vehicle_type} not found in {consumption_path}" - inc_col = df["incline"] - tmp_col = df["t_amb"] - lol_col = df["level_of_loading"] - speed_col = df["mean_speed_kmh"] - cons_col = df["consumption_kwh_per_km"] - data_table = list(zip(inc_col, tmp_col, lol_col, speed_col, cons_col)) - - def interpol_function(this_incline, this_temp, this_lol, this_speed): - input_point = (this_incline, this_temp, this_lol, this_speed) - return util.nd_interp(input_point, data_table) - - self.consumption_files.update({consumption_function: interpol_function}) - - mileage = self.consumption_files[consumption_function]( - this_incline=height_diff / distance, this_temp=temp, - this_lol=level_of_loading, this_speed=mean_speed) + interpol_function = self.consumption_files[consumption_path] + + mileage = interpol_function( + this_incline=height_diff / distance, this_temp=temp, + this_lol=level_of_loading, this_speed=mean_speed) consumed_energy = mileage * distance / 1000 # kWh delta_soc = -1 * (consumed_energy / vehicle_info["capacity"]) return consumed_energy, delta_soc + + def set_consumption_interpolation(self, consumption_lookup_name: str, df: pd.DataFrame): + """ + Set interpolation function for consumption lookup. + + If dataframes contain vehicle_types the name of the vehicle_type will be added and the df + filtered. + + :param consumption_lookup_name: Name for the consumption lookup. + :type consumption_lookup_name: str + :param df: DataFrame containing consumption data. + :type df: pd.DataFrame + """ + + if VEHICLE_TYPE in df.columns: + unique_vts = df[VEHICLE_TYPE].unique() + for vt in unique_vts: + mask = df[VEHICLE_TYPE] == vt + df_vt = df.loc[mask, [INCLINE, SPEED, LEVEL_OF_LOADING, T_AMB, CONSUMPTION]] + interpol_function = self.get_nd_interpolation(df_vt) + vt_specific_name = self.get_consumption_lookup_name(consumption_lookup_name, vt) + if vt_specific_name in self.consumption_files: + warnings.warn("Overwriting exising consumption function") + self.consumption_files.update({vt_specific_name: interpol_function}) + return + + interpol_function = self.get_nd_interpolation(df) + if consumption_lookup_name in self.consumption_files: + warnings.warn("Overwriting exising consumption function") + self.consumption_files.update({consumption_lookup_name: interpol_function}) + + def get_nd_interpolation(self, df): + """ + Get n-dimensional interpolation function. + + :param df: DataFrame containing consumption data. + :type df: pd.DataFrame + :return: Interpolation function. + :rtype: function + """ + + inc_col = df[INCLINE] + tmp_col = df[T_AMB] + lol_col = df[LEVEL_OF_LOADING] + speed_col = df[SPEED] + cons_col = df[CONSUMPTION] + data_table = list(zip(inc_col, tmp_col, lol_col, speed_col, cons_col)) + + def interpol_function(this_incline, this_temp, this_lol, this_speed): + input_point = (this_incline, this_temp, this_lol, this_speed) + return util.nd_interp(input_point, data_table) + + return interpol_function + + def get_temperature(self, time, vehicle_info): + """ + Get temperature for the given time. + + :param time: time of the lookup. + :type time: datetime.datetime + :param vehicle_info: Information about the vehicle. + :type vehicle_info: dict + :return: Temperature. + :rtype: float + :raises AttributeError: if temperature data is not available. + :raises KeyError: if temperature data is not available for the given hour. + """ + + try: + return self.temperatures_by_hour[time.hour] + except AttributeError as e: + raise AttributeError( + "Neither of these conditions is met:\n" + "1. Temperature data is available for every trip through the trips file " + "or a temperature over day file.\n" + f"2. A constant mileage for the vehicle " + f"{vehicle_info['mileage']} is provided." + ) from e + except KeyError as e: + raise KeyError(f"No temperature data for the hour {time.hour} is provided") from e + + def get_level_of_loading(self, time, vehicle_info): + """ + Get level of loading for the given time. + + :param time: time of the lookup. + :type time: datetime.datetime + :param vehicle_info: Information about the vehicle. + :type vehicle_info: dict + :return: Level of loading. + :rtype: float + :raises AttributeError: if level of loading data is not available. + :raises KeyError: if level of loading data is not available for the given hour. + """ + try: + return self.lol_by_hour[time.hour] + except AttributeError as e: + raise AttributeError( + "Neither of these conditions is met:\n" + "1. Level of loading data is available for every trip through the trips file " + "or a level of loading over day file.\n" + f"2. A constant mileage for the vehicle " + f"{vehicle_info['mileage']} is provided." + ) from e + except KeyError as e: + raise KeyError(f"No level of loading for the hour {time.hour} is provided") from e + + def get_consumption_lookup_name(self, consumption_path, vehicle_type): + """ + Get name for the consumption lookup. + + :param consumption_path: Path to consumption data. + :type consumption_path: str + :param vehicle_type: Type of vehicle. + :type vehicle_type: str + :return: Name for the consumption lookup. + :rtype: str + """ + + consumption_function = vehicle_type + "_from_" + consumption_path + return consumption_function diff --git a/simba/data_container.py b/simba/data_container.py new file mode 100644 index 00000000..cce15d8c --- /dev/null +++ b/simba/data_container.py @@ -0,0 +1,86 @@ +"""Module to handle data access, by the varying SimBA modules.""" +from pathlib import Path +from typing import Dict + +import pandas as pd + +from simba import util +from simba.consumption import Consumption +from simba.ids import INCLINE, LEVEL_OF_LOADING, SPEED, T_AMB, CONSUMPTION + + +class DataContainer: + def __init__(self): + self.vehicle_types_data: Dict[str, any] = {} + self.consumption_data: Dict[str, pd.DataFrame] = {} + + def add_vehicle_types(self, data: dict) -> None: + """Add vehicle_type data to the data container. Vehicle_types will be stored in the + Schedule instance + + :param data: data containing vehicle_types + :type data: dict + """ + self.vehicle_types_data = data + + def add_vehicle_types_from_json(self, file_path: Path): + try: + with open(file_path, encoding='utf-8') as f: + vehicle_types = util.uncomment_json_file(f) + except FileNotFoundError: + raise Exception(f"Path to vehicle types ({file_path}) " + "does not exist. Exiting...") + self.add_vehicle_types(vehicle_types) + + def add_consumption_data_from_vehicle_type_linked_files(self): + assert self.vehicle_types_data, "No vehicle_type data in the data_container" + mileages = list(get_values_from_nested_key("mileage", self.vehicle_types_data)) + mileages = list(filter(lambda x: isinstance(x, str) or isinstance(x, Path), mileages, )) + # Cast mileages to set, since files only have to be read once + for mileage_path in set(mileages): + # creating the interpol function from csv file. + delim = util.get_csv_delim(mileage_path) + df = pd.read_csv(mileage_path, sep=delim) + self.add_consumption_data(mileage_path, df) + + def add_consumption_data(self, data_name, df: pd.DataFrame) -> None: + """Add consumption data to the data container. Consumption data will be used by the + Consumption instance + + data_name must be equal to the mileage attribute in vehicle_types. E.g. Vehicle type + '12m_opp' has a mileage of 'consumption_12m.csv' -> data_name ='consumption_12m.csv' + :param data_name: name of the data, linked with vehicle_type + :type data_name: str + :param df: dataframe with consumption data and various expected columns + :type df: pd.DataFrame""" + for expected_col in [INCLINE, T_AMB, LEVEL_OF_LOADING, SPEED, CONSUMPTION]: + assert expected_col in df.columns, f"Consumption data is missing {expected_col}" + assert data_name not in self.consumption_data, f"{data_name} already exists in data" + self.consumption_data[data_name] = df + + def to_consumption(self) -> Consumption: + """Build a consumption instance from the stored data + :returns: Consumption instance + """ + # setup consumption calculator that can be accessed by all trips + consumption = Consumption(self.vehicle_types_data) + for name, df in self.consumption_data.items(): + consumption.set_consumption_interpolation(name, df) + return consumption + + +def get_values_from_nested_key(key, data: dict) -> list: + """Get all the values of the specified key in a nested dict + + :param key: key to find + :param data: data to search through + :yields: values of key in data + """ + + try: + yield data[key] + except KeyError: + pass + for value in data.values(): + if isinstance(value, dict): + yield from get_values_from_nested_key(key, value) diff --git a/simba/ids.py b/simba/ids.py new file mode 100644 index 00000000..865293f3 --- /dev/null +++ b/simba/ids.py @@ -0,0 +1,7 @@ +# String Lookups expected in the Dataframes containing Consumption data +INCLINE = "incline" +T_AMB = "t_amb" +LEVEL_OF_LOADING = "level_of_loading" +SPEED = "mean_speed_kmh" +CONSUMPTION = "consumption_kwh_per_km" +VEHICLE_TYPE = "vehicle_type" diff --git a/simba/optimizer_util.py b/simba/optimizer_util.py index e958f148..ece0854b 100644 --- a/simba/optimizer_util.py +++ b/simba/optimizer_util.py @@ -18,8 +18,6 @@ if typing.TYPE_CHECKING: from simba.station_optimizer import StationOptimizer -from simba.consumption import Consumption -from simba.trip import Trip from simba.util import get_buffer_time as get_buffer_time_util from spice_ev.report import generate_soc_timeseries @@ -740,8 +738,7 @@ def run_schedule(sched, args, electrified_stations=None): """ sched_copy = copy(sched) sched_copy.stations = electrified_stations - sched_copy, new_scen = preprocess_schedule(sched_copy, args, - electrified_stations=electrified_stations) + new_scen = sched_copy.generate_scenario(args) with warnings.catch_warnings(): warnings.simplefilter('ignore', UserWarning) @@ -756,32 +753,6 @@ def run_schedule(sched, args, electrified_stations=None): return sched_copy, new_scen -def preprocess_schedule(sched, args, electrified_stations=None): - """ Calculate consumption, set electrified stations and assign vehicles. - - Prepare the schedule by calculating consumption, setting electrified stations and assigning - vehicles - - :param sched: schedule containing the rotations - :type sched: simba.schedule.Schedule - :param args: arguments for simulation - :type args: Namespace - :param electrified_stations: stations to be electrified - :type electrified_stations: dict - :return: schedule and scenario to be simulated - :rtype: (simba.schedule.Schedule, spice_ev.Scenario) - """ - Trip.consumption = Consumption( - sched.vehicle_types, outside_temperatures=args.outside_temperature_over_day_path, - level_of_loading_over_day=args.level_of_loading_over_day_path) - - sched.stations = electrified_stations - sched.calculate_consumption() - sched.assign_vehicles() - - return sched, sched.generate_scenario(args) - - def get_time(start=[]): """Prints the time which passed since the first function call. diff --git a/simba/report.py b/simba/report.py index ae5c1504..e66319e5 100644 --- a/simba/report.py +++ b/simba/report.py @@ -4,9 +4,10 @@ import datetime import logging import matplotlib.pyplot as plt +import matplotlib +matplotlib.use('Agg') from spice_ev.report import aggregate_global_results, plot, generate_reports - def open_for_csv(filepath): return open(filepath, "w", newline='', encoding='utf-8') diff --git a/simba/rotation.py b/simba/rotation.py index c772d270..133729f9 100644 --- a/simba/rotation.py +++ b/simba/rotation.py @@ -108,9 +108,17 @@ def set_charging_type(self, ct): # consumption may have changed with new charging type self.consumption = self.calculate_consumption() - # calculate earliest possible departure for this bus after completion - # of this rotation - if ct == "depb": + # recalculate consumption + self.schedule.consumption += self.consumption - old_consumption + + @property + def earliest_departure_next_rot(self): + return self.arrival_time + datetime.timedelta(hours=self.min_standing_time) + + @property + def min_standing_time(self): + assert self.charging_type in ["depb", "oppb"] + if self.charging_type == "depb": capacity_depb = self.schedule.vehicle_types[self.vehicle_type]["depb"]["capacity"] # minimum time needed to recharge consumed power from depot charger min_standing_time = (self.consumption / self.schedule.cs_power_deps_depb) @@ -119,13 +127,10 @@ def set_charging_type(self, ct): * self.schedule.min_recharge_deps_depb) if min_standing_time > desired_max_standing_time: min_standing_time = desired_max_standing_time - elif ct == "oppb": + elif self.charging_type == "oppb": capacity_oppb = self.schedule.vehicle_types[self.vehicle_type]["oppb"]["capacity"] min_standing_time = ((capacity_oppb / self.schedule.cs_power_deps_oppb) * self.schedule.min_recharge_deps_oppb) - - self.earliest_departure_next_rot = \ - self.arrival_time + datetime.timedelta(hours=min_standing_time) - - # recalculate consumption - self.schedule.consumption += self.consumption - old_consumption + else: + raise not NotImplementedError + return min_standing_time diff --git a/simba/schedule.py b/simba/schedule.py index 0f25c494..9f204714 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -2,13 +2,42 @@ import datetime import json import logging -from pathlib import Path import random import warnings +from pathlib import Path +from typing import Dict, Type, Iterable + +from spice_ev.scenario import Scenario +import simba.rotation from simba import util from simba.rotation import Rotation -from spice_ev.scenario import Scenario + + +class SocDispatcher: + """Dispatches the right initial SoC for every vehicle id at scenario generation. + + Used for specific vehicle initialization for example when coupling tools.""" + + def __init__(self, + default_soc_deps: float, + default_soc_opps: float, + # vehicle_socs stores the departure soc of a rotation as a dict of the previous + # trip, since this is how the SpiceEV scenario is generated. + # The first trip of a vehicle has no previous trip and therefore is None + vehicle_socs: Dict[str, Type[Dict["simba.trip.Trip", float]]] = None): + self.default_soc_deps = default_soc_deps + self.default_soc_opps = default_soc_opps + self.vehicle_socs = {} + if vehicle_socs is not None: + self.vehicle_socs = vehicle_socs + + def get_soc(self, vehicle_id: str, trip: "simba.trip.Trip", station_type: str = None): + try: + v_socs = self.vehicle_socs[vehicle_id] + return v_socs[trip] + except KeyError: + return vars(self).get("default_soc_" + station_type) class Schedule: @@ -28,12 +57,12 @@ def __init__(self, vehicle_types, stations, **kwargs): """ self.stations = stations - self.rotations = {} + self.rotations: Dict[str, simba.rotation.Rotation] = {} self.consumption = 0 self.vehicle_types = vehicle_types self.original_rotations = None self.station_data = None - + self.soc_dispatcher: SocDispatcher = None # mandatory config parameters mandatory_options = [ "min_recharge_deps_oppb", @@ -72,6 +101,19 @@ def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): station_data = dict() station_path = kwargs.get("station_data_path") + level_of_loading_path = kwargs.get("level_of_loading_over_day_path", None) + + if level_of_loading_path is not None: + index = "hour" + column = "level_of_loading" + level_of_loading_dict = cls.get_dict_from_csv(column, level_of_loading_path, index) + + temperature_path = kwargs.get("outside_temperature_over_day_path", None) + if temperature_path is not None: + index = "hour" + column = "temperature" + temperature_data_dict = cls.get_dict_from_csv(column, temperature_path, index) + # find the temperature and elevation of the stations by reading the .csv file. # this data is stored in the schedule and passed to the trips, which use the information # for consumption calculation. Missing station data is handled with default values. @@ -101,7 +143,39 @@ def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): rotation_id = trip['rotation_id'] # trip gets reference to station data and calculates height diff during trip # initialization. Could also get the height difference from here on - trip["station_data"] = station_data + # get average hour of trip if level of loading or temperature has to be read from + # auxiliary tabular data + arr_time = datetime.datetime.fromisoformat(trip["arrival_time"]) + dep_time = datetime.datetime.fromisoformat(trip["departure_time"]) + + # get average hour of trip and parse to string, since tabular data has strings + # as keys + hour = (dep_time + (arr_time - dep_time) / 2).hour + # Get height difference from station_data + try: + height_diff = station_data[trip["arrival_name"]]["elevation"] \ + - station_data[trip["departure_name"]]["elevation"] + except (KeyError, TypeError): + height_diff = 0 + trip["height_diff"] = height_diff + + # Get level of loading from trips.csv or from file + try: + # Clip level of loading to [0,1] + lol = max(0, min(float(trip["level_of_loading"]), 1)) + # In case of empty temperature column or no column at all + except (KeyError, ValueError): + lol = level_of_loading_dict[hour] + trip["level_of_loading"] = lol + + # Get temperature from trips.csv or from file + try: + # Cast temperature to float + temperature = float(trip["temperature"]) + # In case of empty temperature column or no column at all + except (KeyError, ValueError): + temperature = temperature_data_dict[hour] + trip["temperature"] = temperature if rotation_id not in schedule.rotations.keys(): schedule.rotations.update({ rotation_id: Rotation(id=rotation_id, @@ -109,7 +183,7 @@ def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): schedule=schedule)}) schedule.rotations[rotation_id].add_trip(trip) - # set charging type for all rotations without explicitly specified charging type + # set charging type for all rotations without explicitly specified charging type. # charging type may have been set above if a trip of a rotation has a specified # charging type for rot in schedule.rotations.values(): @@ -135,6 +209,16 @@ def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): return schedule + @classmethod + def get_dict_from_csv(cls, column, file_path, index): + output = dict() + with open(file_path, "r") as f: + delim = util.get_csv_delim(file_path) + reader = csv.DictReader(f, delimiter=delim) + for row in reader: + output[float(row[index])] = float(row[column]) + return output + @classmethod def check_consistency(cls, schedule): """ @@ -185,9 +269,18 @@ def check_consistency(cls, schedule): return inconsistent_rotations def run(self, args): - # each rotation is assigned a vehicle ID - self.assign_vehicles() + """Runs a schedule without assigning vehicles. + For external usage the core run functionality is accessible through this function. It + allows for defining a custom-made assign_vehicles method for the schedule. + :param args: used arguments are rotation_filter, path to rotation ids, + and rotation_filter_variable that sets mode (options: include, exclude) + :type args: argparse.Namespace + :return: scenario + :rtype spice_ev.Scenario + """ + # Make sure all rotations have an assigned vehicle + assert all([rot.vehicle_id is not None for rot in self.rotations.values()]) scenario = self.generate_scenario(args) logging.info("Running SpiceEV...") @@ -216,10 +309,81 @@ def set_charging_type(self, ct, rotation_ids=None): if rotation_ids is None or id in rotation_ids: rot.set_charging_type(ct) + def assign_vehicles_for_django(self, eflips_output: Iterable[dict]): + """Assign vehicles based on eflips outputs + + eflips couples vehicles and returns for every rotation the departure soc and vehicle id. + This is included into simba by assigning new vehicles with the respective values. I.e. in + simba every rotation gets a new vehicle. + :param eflips_output: output from eflips meant for simba. Iterable contains + rotation_id, vehicle_id and start_soc for each rotation + :type eflips_output: iterable of dataclass "simba_input" + :raises KeyError: If not every rotation has a vehicle assigned to it + """ + eflips_rot_dict = {d["rot"]: {"v_id": d["v_id"], "soc": d["soc"]} for d in eflips_output} + unique_vids = {d["v_id"] for d in eflips_output} + vehicle_socs = {v_id: dict() for v_id in unique_vids} + eflips_vid_dict = {v_id: sorted([d["rot"] for d in eflips_output + if d["v_id"] == v_id], + key=lambda r_id: self.rotations[r_id].departure_time) + for v_id in unique_vids} + + # Calculate vehicle counts + # count number of vehicles per type + # used for unique vehicle id e.g. vehicletype_chargingtype_id + vehicle_type_counts = {f'{vehicle_type}_{charging_type}': 0 + for vehicle_type, charging_types in self.vehicle_types.items() + for charging_type in charging_types.keys()} + for vid in unique_vids: + v_ls = vid.split("_") + vehicle_type_counts[f'{v_ls[0]}_{v_ls[1]}'] += 1 + + self.vehicle_type_counts = vehicle_type_counts + + rotations = sorted(self.rotations.values(), key=lambda rot: rot.departure_time) + for rot in rotations: + try: + v_id = eflips_rot_dict[rot.id]["v_id"] + except KeyError as exc: + raise KeyError(f"SoC-data does not include the rotation with the id: {rot.id}. " + "Externally generated vehicles assignments need to include all " + "rotations") from exc + rot.vehicle_id = v_id + index = eflips_vid_dict[v_id].index(rot.id) + # if this rotation is not the first rotation of the vehicle, find the previous trip + if index != 0: + prev_rot_id = eflips_vid_dict[v_id][index - 1] + trip = self.rotations[prev_rot_id].trips[-1] + else: + # if the rotation has no previous trip, trip is set as None + trip = None + vehicle_socs[v_id][trip] = eflips_rot_dict[rot.id]["soc"] + self.soc_dispatcher.vehicle_socs = vehicle_socs + + def init_soc_dispatcher(self, args): + self.soc_dispatcher = SocDispatcher(default_soc_deps=args.desired_soc_deps, + default_soc_opps=args.desired_soc_opps) + + def assign_only_new_vehicles(self): + """ Assign new vehicle IDs to rotations + """ + # count number of vehicles per type + # used for unique vehicle id e.g. vehicletype_chargingtype_id + vehicle_type_counts = {f'{vehicle_type}_{charging_type}': 0 + for vehicle_type, charging_types in self.vehicle_types.items() + for charging_type in charging_types.keys()} + rotations = sorted(self.rotations.values(), key=lambda rot: rot.departure_time) + for rot in rotations: + vt_ct = f"{rot.vehicle_type}_{rot.charging_type}" + # no vehicle available for dispatch, generate new one + vehicle_type_counts[vt_ct] += 1 + rot.vehicle_id = f"{vt_ct}_{vehicle_type_counts[vt_ct]}" + self.vehicle_type_counts = vehicle_type_counts + def assign_vehicles(self): """ Assign vehicle IDs to rotations. A FIFO approach is used. For every rotation it is checked whether vehicles with matching type are idle, in which - case the one with longest standing time since last rotation is used. + case the one with the longest standing time since last rotation is used. If no vehicle is available a new vehicle ID is generated. """ rotations_in_progress = [] @@ -515,8 +679,9 @@ def generate_scenario(self, args): # a depot bus cannot charge at an opp station station_type = None else: - # get desired soc by station type - desired_soc = vars(args).get("desired_soc_" + station_type) + # get desired soc by station type and trip + desired_soc = self.soc_dispatcher.get_soc( + vehicle_id=vehicle_id, trip=trip, station_type=station_type) except KeyError: # non-electrified station station_type = None @@ -607,11 +772,19 @@ def generate_scenario(self, args): # initial condition of vehicle if i == 0: + gc_name = trip.departure_name + try: + departure_station_type = self.stations[gc_name]["type"] + except KeyError: + departure_station_type = "deps" + vehicles[vehicle_id] = { "connected_charging_station": None, "estimated_time_of_departure": trip.departure_time.isoformat(), "desired_soc": None, - "soc": args.desired_soc_deps, + "soc": self.soc_dispatcher.get_soc(vehicle_id=vehicle_id, + trip=None, + station_type=departure_station_type), "vehicle_type": f"{trip.rotation.vehicle_type}_{trip.rotation.charging_type}" } diff --git a/simba/simulate.py b/simba/simulate.py index 460523ce..27711caf 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -1,8 +1,9 @@ import logging import traceback +from copy import deepcopy from simba import report, optimization, util -from simba.consumption import Consumption +from simba.data_container import DataContainer from simba.costs import calculate_costs from simba.optimizer_util import read_config as read_optimizer_config from simba.schedule import Schedule @@ -23,12 +24,25 @@ def simulate(args): :return: final schedule and scenario :rtype: tuple """ - schedule = pre_simulation(args) + # The data_container stores various input data. + data_container = create_and_fill_data_container(args) + + schedule, args = pre_simulation(args, data_container) scenario = schedule.run(args) - return modes_simulation(schedule, scenario, args) + schedule, scenario = modes_simulation(schedule, scenario, args) + return schedule, scenario + +def create_and_fill_data_container(args): + data_container = DataContainer() + # Add the vehicle_types from a json file + data_container.add_vehicle_types_from_json(args.vehicle_types_path) + # Add consumption data, which is found in the vehicle_type data + data_container.add_consumption_data_from_vehicle_type_linked_files() + return data_container -def pre_simulation(args): + +def pre_simulation(args, data_container: DataContainer): """ Prepare simulation. @@ -36,16 +50,14 @@ def pre_simulation(args): :param args: arguments :type args: Namespace + :param data_container: data needed for simulation + :type data_container: DataContainer :raises Exception: If an input file does not exist, exit the program. - :return: schedule - :rtype: simba.schedule.Schedule + :return: schedule, args + :rtype: simba.schedule.Schedule, Namespace """ - try: - with open(args.vehicle_types_path, encoding='utf-8') as f: - vehicle_types = util.uncomment_json_file(f) - except FileNotFoundError: - raise Exception(f"Path to vehicle types ({args.vehicle_types_path}) " - "does not exist. Exiting...") + # Deepcopy args so original args do not get mutated, i.e. deleted + args = deepcopy(args) # load stations file try: @@ -64,14 +76,12 @@ def pre_simulation(args): raise Exception(f"Path to cost parameters ({args.cost_parameters_file}) " "does not exist. Exiting...") - # setup consumption calculator that can be accessed by all trips - Trip.consumption = Consumption( - vehicle_types, - outside_temperatures=args.outside_temperature_over_day_path, - level_of_loading_over_day=args.level_of_loading_over_day_path) + # Add consumption calculator to trip class + Trip.consumption = data_container.to_consumption() # generate schedule from csv - schedule = Schedule.from_csv(args.input_schedule, vehicle_types, stations, **vars(args)) + schedule = Schedule.from_csv(args.input_schedule, data_container.vehicle_types_data, stations, + **vars(args)) # filter rotations schedule.rotation_filter(args) @@ -79,7 +89,13 @@ def pre_simulation(args): # calculate consumption of all trips schedule.calculate_consumption() - return schedule + # Create soc dispatcher + schedule.init_soc_dispatcher(args) + + # each rotation is assigned a vehicle ID + schedule.assign_vehicles() + + return schedule, args def modes_simulation(schedule, scenario, args): @@ -201,13 +217,17 @@ def station_optimization(schedule, scenario, args, i): "Since no path was given, station optimization is skipped") return schedule, scenario conf = read_optimizer_config(args.optimizer_config) + # Get Copies of the original schedule and scenario. In case of an exception the outer + # schedule and scenario stay intact. + original_schedule = deepcopy(schedule) + original_scenario = deepcopy(scenario) try: create_results_directory(args, i+1) return run_optimization(conf, sched=schedule, scen=scenario, args=args) except Exception as err: logging.warning('During Station optimization an error occurred {0}. ' 'Optimization was skipped'.format(err)) - return schedule, scenario + return original_schedule, original_scenario def remove_negative(schedule, scenario, args, _i): neg_rot = schedule.get_negative_rotations(scenario) diff --git a/simba/station_optimization.py b/simba/station_optimization.py index 54ff7309..cae1033f 100644 --- a/simba/station_optimization.py +++ b/simba/station_optimization.py @@ -86,6 +86,7 @@ def run_optimization(conf, sched=None, scen=None, args=None): :type scen: spice_ev.Scenario :param args: Simulation arguments for manipulation of generated outputs :type args: Namespace + :raises Exception: if no rotations can be optimized :return: optimized schedule and Scenario :rtype: tuple(simba.schedule.Schedule, spice_ev.Scenario) @@ -111,7 +112,6 @@ def run_optimization(conf, sched=None, scen=None, args=None): if args.desired_soc_deps != 1 and conf.solver == "quick": logger.error("Fast calculation is not yet optimized for desired socs different to 1") - optimizer = simba.station_optimizer.StationOptimizer(sched, scen, args, conf, logger) # set battery and charging curves through config file @@ -123,7 +123,8 @@ def run_optimization(conf, sched=None, scen=None, args=None): r for r in sched.rotations if "depb" == sched.rotations[r].charging_type) sched.rotations = {r: sched.rotations[r] for r in sched.rotations if "oppb" == sched.rotations[r].charging_type} - assert len(sched.rotations) > 0, "No rotations left after removing depot chargers" + if len(sched.rotations) < 1: + raise Exception("No rotations left after removing depot chargers") # rebasing the scenario meaning simulating it again with SpiceEV and the given conditions of # included stations, excluded stations, filtered rotations and changed battery sizes diff --git a/tests/helpers.py b/tests/helpers.py index a05fb5ae..a4b463a0 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,6 +1,7 @@ """ Reusable functions that support tests """ from simba import schedule, trip, consumption, util +from simba.data_container import DataContainer def generate_basic_schedule(): @@ -10,9 +11,8 @@ def generate_basic_schedule(): lol_path = 'data/examples/default_level_of_loading_over_day.csv' with open("data/examples/vehicle_types.json", 'r', encoding='utf-8') as f: vehicle_types = util.uncomment_json_file(f) - trip.Trip.consumption = consumption.Consumption(vehicle_types, - outside_temperatures=temperature_path, - level_of_loading_over_day=lol_path) + + initialize_consumption(vehicle_types) mandatory_args = { "min_recharge_deps_oppb": 0, @@ -24,4 +24,16 @@ def generate_basic_schedule(): "cs_power_deps_oppb": 150 } - return schedule.Schedule.from_csv(schedule_path, vehicle_types, station_path, **mandatory_args) + return schedule.Schedule.from_csv(schedule_path, vehicle_types, station_path, **mandatory_args, + outside_temperature_over_day_path=temperature_path, + level_of_loading_over_day_path=lol_path) + + +def initialize_consumption(vehicle_types): + data_container = DataContainer() + data_container.add_vehicle_types(vehicle_types) + data_container.add_consumption_data_from_vehicle_type_linked_files() + trip.Trip.consumption = consumption.Consumption(vehicle_types) + for name, df in data_container.consumption_data.items(): + trip.Trip.consumption.set_consumption_interpolation(name, df) + return trip.Trip.consumption diff --git a/tests/test_consumption.py b/tests/test_consumption.py index 5dae0408..575ca367 100644 --- a/tests/test_consumption.py +++ b/tests/test_consumption.py @@ -1,5 +1,5 @@ import pytest -from tests.test_schedule import TestSchedule +from tests.test_schedule import basic_run from tests.conftest import example_root from datetime import datetime import pandas as pd @@ -14,7 +14,7 @@ def test_calculate_consumption(self, tmp_path): :param tmp_path: pytest fixture to create a temporary path """ - schedule, scenario, _ = TestSchedule().basic_run() + schedule, scenario, _ = basic_run() trip = next(iter(schedule.rotations.values())).trips.pop(0) consumption = trip.__class__.consumption consumption.temperatures_by_hour = {hour: hour * 2 - 15 for hour in range(0, 24)} @@ -49,11 +49,9 @@ def true_cons(lol, incline, speed, t_amb): consumption_col[:] = true_cons(lol_col, incline_col, speed_col, temp_col) # save the file in a temp folder and use from now on - consumption_df.to_csv(tmp_path / "consumption.csv") - consumption_path = tmp_path / "consumption.csv" - - vehicle[1][charging_type]["mileage"] = consumption_path - consumption.vehicle_types[vehicle_type][charging_type] = vehicle[1][charging_type] + vehicle[1][charging_type]["mileage"] = "new_consumption" + consumption.set_consumption_interpolation(vehicle[1][charging_type]["mileage"], + consumption_df) lol = 0.5 incline = 0 diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 76d770c9..401df3db 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -6,11 +6,10 @@ import spice_ev.scenario as scenario from spice_ev.util import set_options_from_config -from simba.simulate import pre_simulation +from simba.simulate import pre_simulation, create_and_fill_data_container from tests.conftest import example_root, file_root -from tests.helpers import generate_basic_schedule -from simba import consumption, rotation, schedule, trip, util - +from tests.helpers import generate_basic_schedule, initialize_consumption +from simba import rotation, schedule, util mandatory_args = { "min_recharge_deps_oppb": 0, @@ -66,7 +65,8 @@ def basic_run(self): args.ALLOW_NEGATIVE_SOC = True args.attach_vehicle_soc = True - sched = pre_simulation(args) + data_container = create_and_fill_data_container(args) + sched, args = pre_simulation(args, data_container) scen = sched.run(args) return sched, scen, args @@ -82,33 +82,26 @@ def test_mandatory_options_exit(self): schedule.Schedule(self.vehicle_types, self.electrified_stations, **args) args[key] = value - def test_station_data_reading(self): + def test_station_data_reading(self, default_schedule_arguments): """ Test if the reading of the geo station data works and outputs warnings in - case the data was problematic, e.g. not numeric or not existent""" - path_to_trips = example_root / "trips_example.csv" + case the data was problematic, e.g. not numeric or not existent - trip.Trip.consumption = consumption.Consumption( - self.vehicle_types, outside_temperatures=self.temperature_path, - level_of_loading_over_day=self.lol_path) + :param default_schedule_arguments: basic arguments the schedule needs for creation + """ + initialize_consumption(self.vehicle_types) - path_to_all_station_data = example_root / "all_stations.csv" - generated_schedule = schedule.Schedule.from_csv( - path_to_trips, self.vehicle_types, self.electrified_stations, **mandatory_args, - station_data_path=path_to_all_station_data) + default_schedule_arguments["path_to_csv"] = example_root / "trips_example.csv" + generated_schedule = schedule.Schedule.from_csv(**default_schedule_arguments) assert generated_schedule.station_data is not None # check if reading a non valid station.csv throws warnings with pytest.warns(Warning) as record: - path_to_all_station_data = file_root / "not_existent_file" - schedule.Schedule.from_csv( - path_to_trips, self.vehicle_types, self.electrified_stations, **mandatory_args, - station_data_path=path_to_all_station_data) + default_schedule_arguments["station_data_path"] = file_root / "not_existent_file" + schedule.Schedule.from_csv(**default_schedule_arguments) assert len(record) == 1 - path_to_all_station_data = file_root / "not_numeric_stations.csv" - schedule.Schedule.from_csv( - path_to_trips, self.vehicle_types, self.electrified_stations, **mandatory_args, - station_data_path=path_to_all_station_data) + default_schedule_arguments["station_data_path"] = file_root / "not_numeric_stations.csv" + schedule.Schedule.from_csv(**default_schedule_arguments) assert len(record) == 2 def test_basic_run(self): @@ -117,38 +110,37 @@ def test_basic_run(self): sched, scen, args = self.basic_run() assert type(scen) is scenario.Scenario - def test_assign_vehicles(self): + def test_assign_vehicles(self, default_schedule_arguments): """ Test if assigning vehicles works as intended. Use a trips csv with two rotations ("1","2") a day apart. SimBA should assign the same vehicle to both of them. Rotation "3" starts shortly after "2" and should be a new vehicle. + + :param default_schedule_arguments: basic arguments the schedule needs for creation """ - trip.Trip.consumption = consumption.Consumption(self.vehicle_types, - outside_temperatures=self.temperature_path, - level_of_loading_over_day=self.lol_path) + initialize_consumption(self.vehicle_types) - path_to_trips = file_root / "trips_assign_vehicles.csv" - generated_schedule = schedule.Schedule.from_csv( - path_to_trips, self.vehicle_types, self.electrified_stations, **mandatory_args) + default_schedule_arguments["path_to_csv"] = file_root / "trips_assign_vehicles.csv" + generated_schedule = schedule.Schedule.from_csv(**default_schedule_arguments) generated_schedule.assign_vehicles() gen_rotations = generated_schedule.rotations assert gen_rotations["1"].vehicle_id == gen_rotations["2"].vehicle_id assert gen_rotations["1"].vehicle_id != gen_rotations["3"].vehicle_id - def test_calculate_consumption(self): + def test_calculate_consumption(self, default_schedule_arguments): """ Test if calling the consumption calculation works + + :param default_schedule_arguments: basic arguments the schedule needs for creation """ # Changing self.vehicle_types can propagate to other tests vehicle_types = deepcopy(self.vehicle_types) - trip.Trip.consumption = consumption.Consumption( - vehicle_types, outside_temperatures=self.temperature_path, - level_of_loading_over_day=self.lol_path) + initialize_consumption(vehicle_types) - path_to_trips = file_root / "trips_assign_vehicles.csv" - generated_schedule = schedule.Schedule.from_csv( - path_to_trips, vehicle_types, self.electrified_stations, **mandatory_args) + default_schedule_arguments["path_to_csv"] = file_root / "trips_assign_vehicles.csv" + default_schedule_arguments["vehicle_types"] = vehicle_types + generated_schedule = schedule.Schedule.from_csv(**default_schedule_arguments) # set mileage to a constant mileage = 10 @@ -162,18 +154,17 @@ def test_calculate_consumption(self): charge_typ['mileage'] = mileage / 2 assert calc_consumption == generated_schedule.calculate_consumption() * 2 - def test_get_common_stations(self): + def test_get_common_stations(self, default_schedule_arguments): """Test if getting common_stations works. Rotation 1 is on the first day, rotation 2 and 3 on the second day. rotation 1 should not share any stations with other rotations and 2 and 3 are almost simultaneous. + + :param default_schedule_arguments: basic arguments the schedule needs for creation """ - trip.Trip.consumption = consumption.Consumption( - self.vehicle_types, outside_temperatures=self.temperature_path, - level_of_loading_over_day=self.lol_path) + initialize_consumption(self.vehicle_types) - path_to_trips = file_root / "trips_assign_vehicles.csv" - generated_schedule = schedule.Schedule.from_csv( - path_to_trips, self.vehicle_types, self.electrified_stations, **mandatory_args) + default_schedule_arguments["path_to_csv"] = file_root / "trips_assign_vehicles.csv" + generated_schedule = schedule.Schedule.from_csv(**default_schedule_arguments) common_stations = generated_schedule.get_common_stations(only_opps=False) assert len(common_stations["1"]) == 0 @@ -195,8 +186,8 @@ def test_get_negative_rotations(self): neg_rots = sched.get_negative_rotations(scen) assert ['1'] == neg_rots - def test_rotation_filter(self, tmp_path): - s = schedule.Schedule(self.vehicle_types, self.electrified_stations, **mandatory_args) + def test_rotation_filter(self, tmp_path, default_schedule_arguments): + s = schedule.Schedule(**default_schedule_arguments) args = Namespace(**{ "rotation_filter_variable": None, "rotation_filter": None, @@ -256,11 +247,12 @@ def test_rotation_filter(self, tmp_path): s.rotation_filter(args, rf_list=[]) assert not s.rotations - def test_scenario_with_feed_in(self): + def test_scenario_with_feed_in(self, default_schedule_arguments): """ Check if running a example with an extended electrified stations file - with feed in, external load and battery works and if a scenario object is returned""" + with feed in, external load and battery works and if a scenario object is returned - path_to_trips = example_root / "trips_example.csv" + :param default_schedule_arguments: basic arguments the schedule needs for creation + """ sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] args = util.get_args() args.config = example_root / "simba.cfg" @@ -272,18 +264,20 @@ def test_scenario_with_feed_in(self): args.days = None args.seed = 5 - trip.Trip.consumption = consumption.Consumption( - self.vehicle_types, outside_temperatures=self.temperature_path, - level_of_loading_over_day=self.lol_path) + initialize_consumption(self.vehicle_types) - path_to_all_station_data = example_root / "all_stations.csv" - generated_schedule = schedule.Schedule.from_csv( - path_to_trips, self.vehicle_types, electrified_stations, **mandatory_args, - station_data_path=path_to_all_station_data) + default_schedule_arguments["path_to_csv"] = example_root / "trips_example.csv" + default_schedule_arguments["stations"] = electrified_stations + default_schedule_arguments["station_data_path"] = example_root / "all_stations.csv" + default_schedule_arguments["path_to_trips"] = example_root / "trips_example.csv" + generated_schedule = schedule.Schedule.from_csv(**default_schedule_arguments) + # Create soc dispatcher + generated_schedule.init_soc_dispatcher(args) set_options_from_config(args, verbose=False) args.ALLOW_NEGATIVE_SOC = True args.attach_vehicle_soc = True + scen = generated_schedule.generate_scenario(args) assert "Station-0" in scen.components.photovoltaics assert "Station-3" in scen.components.photovoltaics @@ -291,6 +285,7 @@ def test_scenario_with_feed_in(self): assert scen.components.batteries["Station-0 storage"].capacity == 300 assert scen.components.batteries["Station-0 storage"].efficiency == 0.95 assert scen.components.batteries["Station-0 storage"].min_charging_power == 0 + generated_schedule.assign_vehicles() scen = generated_schedule.run(args) assert type(scen) is scenario.Scenario @@ -299,9 +294,12 @@ def test_scenario_with_feed_in(self): electrified_stations["Station-0"]["energy_feed_in"]["csv_file"] = file_root / "notafile" electrified_stations["Station-0"]["external_load"]["csv_file"] = file_root / "notafile" - generated_schedule = schedule.Schedule.from_csv( - path_to_trips, self.vehicle_types, electrified_stations, **mandatory_args, - station_data_path=path_to_all_station_data) + + default_schedule_arguments["stations"] = electrified_stations + generated_schedule = schedule.Schedule.from_csv(**default_schedule_arguments) + + # Create soc dispatcher + generated_schedule.init_soc_dispatcher(args) set_options_from_config(args, verbose=False) diff --git a/tests/test_simulate.py b/tests/test_simulate.py index 83e0eadd..4b184aca 100644 --- a/tests/test_simulate.py +++ b/tests/test_simulate.py @@ -49,7 +49,7 @@ def test_basic(self): def test_missing(self): # every value in DEFAULT_VALUES is expected to be set, so omitting one should raise an error values = self.DEFAULT_VALUES.copy() - # except propagate_modes_error + # except propagate_modes_error, since this is only checked when error needs propagation del self.DEFAULT_VALUES["propagate_mode_errors"] for k, v in self.DEFAULT_VALUES.items(): del values[k] diff --git a/tests/test_soc_dispatcher.py b/tests/test_soc_dispatcher.py new file mode 100644 index 00000000..2dc605f7 --- /dev/null +++ b/tests/test_soc_dispatcher.py @@ -0,0 +1,121 @@ +from copy import deepcopy +from datetime import timedelta +import sys +import pandas as pd +import pytest + +from simba.simulate import pre_simulation, create_and_fill_data_container +from simba.trip import Trip +from tests.conftest import example_root +from simba import util + + +class TestSocDispatcher: + def basic_run(self): + """Returns a schedule, scenario and args after running SimBA. + :return: schedule, scenario, args + """ + # set the system variables to imitate the console call with the config argument. + # first element has to be set to something or error is thrown + sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] + args = util.get_args() + args.seed = 5 + args.attach_vehicle_soc = True + data_container = create_and_fill_data_container(args) + sched, args = pre_simulation(args, data_container=data_container) + + # Copy an opportunity rotation twice, so dispatching can be tested + assert sched.rotations["1"].charging_type == "oppb" + sched.rotations["11"] = deepcopy(sched.rotations["1"]) + sched.rotations["12"] = deepcopy(sched.rotations["1"]) + sched.rotations["11"].id = "11" + sched.rotations["12"].id = "12" + + # Mutate the first copy, so it ends later and has higher consumption + sched.rotations["11"].trips[-1].arrival_time += timedelta(minutes=5) + sched.rotations["11"].arrival_time += timedelta(minutes=5) + + sched.rotations["11"].trips[-1].distance += 10000 + # Mutate the second copy, so it starts later than "1" ends. + # This way a prev. vehicle can be used. + dt = sched.rotations["1"].arrival_time - sched.rotations["12"].departure_time + timedelta( + minutes=20) + for t in sched.rotations["12"].trips: + t.arrival_time += dt + t.departure_time += dt + sched.rotations["12"].departure_time += dt + sched.rotations["12"].arrival_time += dt + + # Copy a depot rotation, so a vehicle can be used again + assert sched.rotations["2"].charging_type == "depb" + + sched.rotations["21"] = deepcopy(sched.rotations["2"]) + sched.rotations["21"].id = "21" + dt = sched.rotations["2"].arrival_time - sched.rotations["21"].departure_time + timedelta( + minutes=30) + for t in sched.rotations["21"].trips: + t.arrival_time += dt + t.departure_time += dt + sched.rotations["21"].departure_time += dt + sched.rotations["21"].arrival_time += dt + + for v_type in Trip.consumption.vehicle_types.values(): + for charge_type in v_type.values(): + charge_type["mileage"] = 1 + + # calculate consumption of all trips + sched.calculate_consumption() + sched.rotations["21"].consumption + + # Create soc dispatcher + sched.init_soc_dispatcher(args) + + sched.assign_vehicles() + scen = sched.run(args) + + for rot in sched.rotations.values(): + print(rot.id, rot.vehicle_id) + + return sched, scen, args + + @pytest.fixture + def eflips_output(self): + # eflipsoutput + eflips_output = [] + + eflips_output.append(dict(rot="4", v_id="AB_depb_1", soc=1)) + eflips_output.append(dict(rot="3", v_id="AB_depb_2", soc=0.8)) + eflips_output.append(dict(rot="21", v_id="AB_depb_3", soc=0.69)) + eflips_output.append(dict(rot="2", v_id="AB_depb_3", soc=1)) + eflips_output.append(dict(rot="1", v_id="AB_oppb_1", soc=1)) + eflips_output.append(dict(rot="11", v_id="AB_oppb_2", soc=0.6)) + eflips_output.append(dict(rot="12", v_id="AB_oppb_1", soc=0.945)) + return eflips_output + + def test_basic_dispatching(self, eflips_output): + """Returns a schedule, scenario and args after running SimBA. + :param eflips_output: list of eflips data + :return: schedule, scenario, args + """ + sched, scen, args = self.basic_run() + pd.DataFrame(scen.vehicle_socs).plot() + + sched.assign_vehicles_for_django(eflips_output) + scen = sched.run(args) + + pd.DataFrame(scen.vehicle_socs).plot() + + return sched, scen, args + + def test_basic_missing_rotation(self, eflips_output): + """Test if missing a rotation throws an error + :param eflips_output: list of eflips data + """ + sched, scen, args = self.basic_run() + # delete data for a single rotation but keep the rotation_id + missing_rot_id = eflips_output[-1]["rot"] + del eflips_output[-1] + + # if data for a rotation is missing an error containing the rotation id should be raised + with pytest.raises(KeyError, match=missing_rot_id): + sched.assign_vehicles_for_django(eflips_output) diff --git a/tests/test_station_optimization.py b/tests/test_station_optimization.py index 24ed25be..31b204fa 100644 --- a/tests/test_station_optimization.py +++ b/tests/test_station_optimization.py @@ -6,12 +6,11 @@ import sys import shutil -from simba.consumption import Consumption import simba.optimizer_util as opt_util from simba.schedule import Schedule from simba.station_optimization import run_optimization -from simba.trip import Trip import simba.util as util +from tests.helpers import initialize_consumption from spice_ev.report import generate_soc_timeseries from tests.conftest import example_root @@ -92,18 +91,19 @@ def basic_run(self, trips_file_name="trips.csv"): folder :type trips_file_name: str :return: schedule, scenario""" + path_to_trips = file_root / trips_file_name sys.argv = ["foo", "--config", str(self.tmp_path / "simba.cfg")] args = util.get_args() args.input_schedule = path_to_trips - Trip.consumption = Consumption(self.vehicle_types, - outside_temperatures=None, - level_of_loading_over_day=None) + initialize_consumption(self.vehicle_types) args2 = copy(args) generated_schedule = Schedule.from_csv(path_to_trips, self.vehicle_types, self.electrified_stations, **vars(args2)) - + # Create soc dispatcher + generated_schedule.init_soc_dispatcher(args) + generated_schedule.assign_vehicles() scen = generated_schedule.run(args) # optimization depends on vehicle_socs, therefore they need to be generated generate_soc_timeseries(scen) @@ -194,6 +194,7 @@ def test_deep_optimization(self): trips_file_name = "trips_extended.csv" # adjust mileage so scenario is not possible without adding electrification + self.vehicle_types = adjust_vehicle_file(args.vehicle_types_path, mileage=2, capacity=150) sched, scen, args = self.basic_run(trips_file_name=trips_file_name) # optimization can only be properly tested if negative rotations exist From 6d4cf1a5abc305279bcdba6350bc728d9cf8538b Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 19 Mar 2024 14:09:38 +0100 Subject: [PATCH 03/68] Add simple simulation as mode --- simba/simulate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/simba/simulate.py b/simba/simulate.py index 27711caf..330e9bf2 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -170,6 +170,10 @@ class Mode: A function must return the updated schedule and scenario objects. """ + def sim(schedule, scenario, args, _i):# Noqa + scenario = schedule.run(args) + return schedule, scenario + def service_optimization(schedule, scenario, args, _i): # find largest set of rotations that produce no negative SoC result = optimization.service_optimization(schedule, scenario, args) From 6ed4c0bb99ca59f191a21dc12b66798a7a9a7859 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 19 Mar 2024 14:10:33 +0100 Subject: [PATCH 04/68] Get geo location from stations --- simba/schedule.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/simba/schedule.py b/simba/schedule.py index 9f204714..7a0cf117 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -123,8 +123,10 @@ def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): delim = util.get_csv_delim(station_path) reader = csv.DictReader(f, delimiter=delim) for row in reader: - station_data.update({str(row['Endhaltestelle']): - {"elevation": float(row['elevation'])}}) + station_data.update({str(row['Endhaltestelle']): { + "elevation": float(row['elevation']), "lat": float(row.get('lat', 0)), + "long": float(row.get('long', 0))} + }) except FileNotFoundError or KeyError: warnings.warn("Warning: external csv file '{}' not found or not named properly " "(Needed column names are 'Endhaltestelle' and 'elevation')". From f500df98b37f5f83a56be29156cd028fdfd0ba4d Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 19 Mar 2024 14:12:01 +0100 Subject: [PATCH 05/68] Make temperatur and level of loading mandatory for Trip creation --- simba/trip.py | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/simba/trip.py b/simba/trip.py index 0d7d4713..72646d6f 100644 --- a/simba/trip.py +++ b/simba/trip.py @@ -1,38 +1,23 @@ from datetime import datetime, timedelta +import simba.consumption + class Trip: + consumption: simba.consumption.Consumption = None + def __init__(self, rotation, departure_time, departure_name, - arrival_time, arrival_name, distance, **kwargs): + arrival_time, arrival_name, distance, temperature, level_of_loading, height_diff, + **kwargs): self.departure_name = departure_name self.departure_time = datetime.fromisoformat(departure_time) self.arrival_time = datetime.fromisoformat(arrival_time) self.arrival_name = arrival_name self.distance = float(distance) self.line = kwargs.get('line', None) - self.temperature = kwargs.get('temperature', None) - try: - self.temperature = float(self.temperature) - # In case of empty temperature column or no column at all - except (TypeError, ValueError): - self.temperature = None - - height_diff = kwargs.get("height_difference", None) - if height_diff is None: - station_data = kwargs.get("station_data", dict()) - try: - height_diff = station_data[self.arrival_name]["elevation"] \ - - station_data[self.departure_name]["elevation"] - except (KeyError, TypeError): - height_diff = 0 + self.temperature = float(temperature) self.height_diff = height_diff - self.level_of_loading = kwargs.get('level_of_loading', None) - try: - # Clip level of loading to [0,1] - self.level_of_loading = max(0, min(float(self.level_of_loading), 1)) - # In case of empty temperature column or no column at all - except (TypeError, ValueError): - self.level_of_loading = None + self.level_of_loading = level_of_loading # mean speed in km/h from distance and travel time or from initialization # travel time is at least 1 min mean_speed = kwargs.get("mean_speed", (self.distance / 1000) / From 981bbfb8bdd39fd2efda56a35afe31f93c77658f Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 19 Mar 2024 14:14:51 +0100 Subject: [PATCH 06/68] Refactor util --- simba/util.py | 51 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/simba/util.py b/simba/util.py index bb401cd5..e36b3c0b 100644 --- a/simba/util.py +++ b/simba/util.py @@ -240,8 +240,37 @@ def setup_logging(args, time_str): ) logging.captureWarnings(True) - def get_args(): + parser = get_parser() + + args = parser.parse_args() + + # arguments relevant to SpiceEV, setting automatically to reduce clutter in config + mutate_args_for_spiceev(args) + + # If a config is provided, the config will overwrite previously parsed arguments + set_options_from_config(args, check=parser, verbose=False) + + # rename special options + args.timing = args.eta + + missing = [a for a in ["input_schedule", "electrified_stations"] if vars(args).get(a) is None] + if missing: + raise Exception("The following arguments are required: {}".format(", ".join(missing))) + + return args + + +def mutate_args_for_spiceev(args): + # arguments relevant to SpiceEV, setting automatically to reduce clutter in config + args.strategy = 'distributed' + args.margin = 1 + args.ALLOW_NEGATIVE_SOC = True + args.PRICE_THRESHOLD = -100 # ignore price for charging decisions + + + +def get_parser(): parser = argparse.ArgumentParser( description='SimBA - Simulation toolbox for Bus Applications.') @@ -370,22 +399,4 @@ def get_args(): Input a path to an .cfg file or use the default_optimizer.cfg") parser.add_argument('--config', help='Use config file to set arguments') - - args = parser.parse_args() - - # arguments relevant to SpiceEV, setting automatically to reduce clutter in config - args.strategy = 'distributed' - args.margin = 1 - args.ALLOW_NEGATIVE_SOC = True - args.PRICE_THRESHOLD = -100 # ignore price for charging decisions - - set_options_from_config(args, check=parser, verbose=False) - - # rename special options - args.timing = args.eta - - missing = [a for a in ["input_schedule", "electrified_stations"] if vars(args).get(a) is None] - if missing: - raise Exception("The following arguments are required: {}".format(", ".join(missing))) - - return args + return parser \ No newline at end of file From 4fddf267ca4cb8e836a5cc358a0aa94defbdfcb9 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 19 Mar 2024 17:08:01 +0100 Subject: [PATCH 07/68] Add temperatures to data container --- simba/data_container.py | 20 ++++++++++++++++++++ simba/schedule.py | 15 +++++---------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/simba/data_container.py b/simba/data_container.py index cce15d8c..ca814a0e 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -1,4 +1,5 @@ """Module to handle data access, by the varying SimBA modules.""" +import csv from pathlib import Path from typing import Dict @@ -13,6 +14,11 @@ class DataContainer: def __init__(self): self.vehicle_types_data: Dict[str, any] = {} self.consumption_data: Dict[str, pd.DataFrame] = {} + self.temperature_data: Dict[int, float] = {} + self.level_of_loading_data: Dict[int, float] = {} + + def add_temperature_data_from_json(self, ): + to be implemented def add_vehicle_types(self, data: dict) -> None: """Add vehicle_type data to the data container. Vehicle_types will be stored in the @@ -31,6 +37,8 @@ def add_vehicle_types_from_json(self, file_path: Path): raise Exception(f"Path to vehicle types ({file_path}) " "does not exist. Exiting...") self.add_vehicle_types(vehicle_types) + return self + def add_consumption_data_from_vehicle_type_linked_files(self): assert self.vehicle_types_data, "No vehicle_type data in the data_container" @@ -42,6 +50,7 @@ def add_consumption_data_from_vehicle_type_linked_files(self): delim = util.get_csv_delim(mileage_path) df = pd.read_csv(mileage_path, sep=delim) self.add_consumption_data(mileage_path, df) + return self def add_consumption_data(self, data_name, df: pd.DataFrame) -> None: """Add consumption data to the data container. Consumption data will be used by the @@ -58,6 +67,8 @@ def add_consumption_data(self, data_name, df: pd.DataFrame) -> None: assert data_name not in self.consumption_data, f"{data_name} already exists in data" self.consumption_data[data_name] = df + return self + def to_consumption(self) -> Consumption: """Build a consumption instance from the stored data :returns: Consumption instance @@ -84,3 +95,12 @@ def get_values_from_nested_key(key, data: dict) -> list: for value in data.values(): if isinstance(value, dict): yield from get_values_from_nested_key(key, value) + +def get_dict_from_csv(column, file_path, index): + output = dict() + with open(file_path, "r") as f: + delim = util.get_csv_delim(file_path) + reader = csv.DictReader(f, delimiter=delim) + for row in reader: + output[float(row[index])] = float(row[column]) + return output \ No newline at end of file diff --git a/simba/schedule.py b/simba/schedule.py index 7a0cf117..867f9d2d 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -11,6 +11,7 @@ import simba.rotation from simba import util +from simba.data_container import DataContainer from simba.rotation import Rotation @@ -80,6 +81,10 @@ def __init__(self, vehicle_types, stations, **kwargs): for opt in mandatory_options: setattr(self, opt, kwargs.get(opt)) + @classmethod + def from_container(self, data_container: DataContainer): + to be implemented + @classmethod def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): """Constructs Schedule object from CSV file containing all trips of schedule. @@ -211,16 +216,6 @@ def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): return schedule - @classmethod - def get_dict_from_csv(cls, column, file_path, index): - output = dict() - with open(file_path, "r") as f: - delim = util.get_csv_delim(file_path) - reader = csv.DictReader(f, delimiter=delim) - for row in reader: - output[float(row[index])] = float(row[column]) - return output - @classmethod def check_consistency(cls, schedule): """ From 6c508efffd192e88abd145f66bc2506bb74195f5 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 4 Apr 2024 10:48:03 +0200 Subject: [PATCH 08/68] Fix tests and merge dev --- simba/data_container.py | 4 ++-- simba/schedule.py | 26 +++++++++++++++++++++----- simba/simulate.py | 2 +- tests/helpers.py | 9 +++++---- tests/test_schedule.py | 5 +++++ tests/test_soc_dispatcher.py | 2 ++ 6 files changed, 36 insertions(+), 12 deletions(-) diff --git a/simba/data_container.py b/simba/data_container.py index ca814a0e..d82384c7 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -17,8 +17,8 @@ def __init__(self): self.temperature_data: Dict[int, float] = {} self.level_of_loading_data: Dict[int, float] = {} - def add_temperature_data_from_json(self, ): - to be implemented + # def add_temperature_data_from_csv(self, ): + # to be implemented def add_vehicle_types(self, data: dict) -> None: """Add vehicle_type data to the data container. Vehicle_types will be stored in the diff --git a/simba/schedule.py b/simba/schedule.py index 79af1f37..fbd510b1 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -5,6 +5,7 @@ from pathlib import Path import random import warnings +from typing import Dict, Type, Iterable from spice_ev.scenario import Scenario import spice_ev.util as spice_ev_util @@ -81,9 +82,9 @@ def __init__(self, vehicle_types, stations, **kwargs): for opt in mandatory_options: setattr(self, opt, kwargs.get(opt)) - @classmethod - def from_container(self, data_container: DataContainer): - to be implemented + # @classmethod + # def from_container(self, data_container: DataContainer): + # to be implemented @classmethod def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): @@ -100,8 +101,15 @@ def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): :return: Returns a new instance of Schedule with all trips from csv loaded. :rtype: Schedule """ + if isinstance(stations, (str, Path)): + with open(Path(stations), "r") as f: + stations_dict = util.uncomment_json_file(f) + elif isinstance(stations, dict): + stations_dict = stations + else: + raise NotImplementedError - schedule = cls(vehicle_types, stations, **kwargs) + schedule = cls(vehicle_types, stations_dict , **kwargs) station_data = dict() station_path = kwargs.get("station_data_path") @@ -892,7 +900,15 @@ def generate_scenario(self, args): json.dump(self.scenario, f, indent=2) return Scenario(self.scenario, Path()) - + @classmethod + def get_dict_from_csv(cls, column, file_path, index): + output = dict() + with open(file_path, "r") as f: + delim = util.get_csv_delim(file_path) + reader = csv.DictReader(f, delimiter=delim) + for row in reader: + output[float(row[index])] = float(row[column]) + return output def update_csv_file_info(file_info, gc_name): """ add infos to csv information dictionary from electrified station diff --git a/simba/simulate.py b/simba/simulate.py index 0b470ce3..b6b18732 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -221,7 +221,7 @@ def station_optimization(schedule, scenario, args, i): "Since no path was given, station optimization is skipped") return schedule, scenario conf = read_optimizer_config(args.optimizer_config) - # Get Copies of the original schedule and scenario. In case of an exception the outer + # Get copies of the original schedule and scenario. In case of an exception the outer # schedule and scenario stay intact. original_schedule = deepcopy(schedule) original_scenario = deepcopy(scenario) diff --git a/tests/helpers.py b/tests/helpers.py index a4b463a0..c499151a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -23,10 +23,11 @@ def generate_basic_schedule(): "cs_power_deps_depb": 50, "cs_power_deps_oppb": 150 } - - return schedule.Schedule.from_csv(schedule_path, vehicle_types, station_path, **mandatory_args, - outside_temperature_over_day_path=temperature_path, - level_of_loading_over_day_path=lol_path) + generated_schedule = schedule.Schedule.from_csv(schedule_path, vehicle_types, station_path, **mandatory_args, + outside_temperature_over_day_path=temperature_path, + level_of_loading_over_day_path=lol_path) + generated_schedule.assign_vehicles() + return generated_schedule def initialize_consumption(vehicle_types): diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 565b9c9d..4c867381 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -388,6 +388,7 @@ def test_peak_load_window(self): generated_schedule = generate_basic_schedule() sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] args = util.get_args() + generated_schedule.init_soc_dispatcher(args) for station in generated_schedule.stations.values(): station["gc_power"] = 1000 station.pop("peak_load_window_power", None) @@ -454,6 +455,10 @@ def test_generate_price_lists(self): generated_schedule = generate_basic_schedule() sys.argv = ["", "--config", str(example_root / "simba.cfg")] args = util.get_args() + + generated_schedule.init_soc_dispatcher(args) + + # only test individual price CSV and random price generation args.include_price_csv = None # Station-0: all options diff --git a/tests/test_soc_dispatcher.py b/tests/test_soc_dispatcher.py index 2dc605f7..4444d87a 100644 --- a/tests/test_soc_dispatcher.py +++ b/tests/test_soc_dispatcher.py @@ -83,8 +83,10 @@ def eflips_output(self): # eflipsoutput eflips_output = [] + eflips_output.append(dict(rot="41", v_id="AB_depb_1", soc=1)) eflips_output.append(dict(rot="4", v_id="AB_depb_1", soc=1)) eflips_output.append(dict(rot="3", v_id="AB_depb_2", soc=0.8)) + eflips_output.append(dict(rot="31", v_id="AB_depb_2", soc=0.8)) eflips_output.append(dict(rot="21", v_id="AB_depb_3", soc=0.69)) eflips_output.append(dict(rot="2", v_id="AB_depb_3", soc=1)) eflips_output.append(dict(rot="1", v_id="AB_oppb_1", soc=1)) From 39ccb85d807fc47d243848c6c9f750b4c3cc63f6 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Mon, 27 May 2024 15:25:20 +0200 Subject: [PATCH 09/68] Fix tests --- simba/consumption.py | 8 ++++---- simba/optimization.py | 6 +++++- simba/schedule.py | 26 +++++++++++++++++++++++++- simba/simulate.py | 2 +- simba/trip.py | 6 +++--- tests/helpers.py | 13 ++++++++----- tests/test_consumption.py | 22 +++++++++++----------- tests/test_schedule.py | 5 +---- tests/test_soc_dispatcher.py | 2 +- tests/test_station_optimization.py | 2 +- 10 files changed, 60 insertions(+), 32 deletions(-) diff --git a/simba/consumption.py b/simba/consumption.py index 9f23a994..dbfba0d2 100644 --- a/simba/consumption.py +++ b/simba/consumption.py @@ -34,7 +34,7 @@ def __init__(self, vehicle_types, **kwargs) -> None: self.vehicle_types = vehicle_types def calculate_consumption(self, time, distance, vehicle_type, charging_type, temp=None, - height_diff=0, level_of_loading=None, mean_speed=18): + height_difference=0, level_of_loading=None, mean_speed=18): """ Calculates consumed amount of energy for a given distance. :param time: The date and time at which the trip ends @@ -48,8 +48,8 @@ def calculate_consumption(self, time, distance, vehicle_type, charging_type, tem :type charging_type: str :param temp: Temperature outside of the bus in °Celsius :type temp: float - :param height_diff: difference in height between stations in meters- - :type height_diff: float + :param height_difference: difference in height between stations in meters- + :type height_difference: float :param level_of_loading: Level of loading of the bus between empty (=0) and completely full (=1.0). If None is provided, Level of loading will be interpolated from time series @@ -94,7 +94,7 @@ def calculate_consumption(self, time, distance, vehicle_type, charging_type, tem interpol_function = self.consumption_files[consumption_path] mileage = interpol_function( - this_incline=height_diff / distance, this_temp=temp, + this_incline=height_difference / distance, this_temp=temp, this_lol=level_of_loading, this_speed=mean_speed) consumed_energy = mileage * distance / 1000 # kWh diff --git a/simba/optimization.py b/simba/optimization.py index 902e8bf0..711dd5e0 100644 --- a/simba/optimization.py +++ b/simba/optimization.py @@ -293,6 +293,7 @@ def recombination(schedule, args, trips, depot_trips): depot_trip = generate_depot_trip_data_dict( trip.departure_name, depot_trips, args.default_depot_distance, args.default_mean_speed) + height_difference = schedule.get_height_difference(depot_trip["name"],trip.departure_name) rotation.add_trip({ "departure_time": trip.departure_time - depot_trip["travel_time"], "departure_name": depot_trip["name"], @@ -302,7 +303,7 @@ def recombination(schedule, args, trips, depot_trips): "line": trip.line, "charging_type": charging_type, "temperature": trip.temperature, - # "height_difference": None, # compute from station data + "height_difference": height_difference, "level_of_loading": 0, # no passengers from depot "mean_speed": depot_trip["mean_speed"], "station_data": schedule.station_data, @@ -324,6 +325,8 @@ def recombination(schedule, args, trips, depot_trips): depot_trip = generate_depot_trip_data_dict( trip.arrival_name, depot_trips, args.default_depot_distance, args.default_mean_speed) + height_difference = schedule.get_height_difference(trip.arrival_name, + depot_trip["name"]) depot_trip = { "departure_time": trip.arrival_time, "departure_name": trip.arrival_name, @@ -333,6 +336,7 @@ def recombination(schedule, args, trips, depot_trips): "line": trip.line, "charging_type": charging_type, "temperature": trip.temperature, + "height_difference": height_difference, "level_of_loading": 0, "mean_speed": depot_trip["mean_speed"], "station_data": schedule.station_data, diff --git a/simba/schedule.py b/simba/schedule.py index 2a4b38fe..6025907f 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -90,6 +90,30 @@ def __init__(self, vehicle_types, stations, **kwargs): # def from_container(self, data_container: DataContainer): # to be implemented + def get_height_difference(self, departure_name, arrival_name): + """ Get the height difference of two stations. + + Defaults to 0 if height data is not found + :param departure_name: Departure station + :type departure_name: str + :param arrival_name: Arrival station + :type arrival_name: str + :return: Height difference + :rtype: float + """ + if isinstance(self.station_data, dict): + station = departure_name + try: + start_height = self.station_data[station]["elevation"] + station = arrival_name + end_height = self.station_data[arrival_name]["elevation"] + return end_height - start_height + except KeyError: + logging.error(f"No elevation data found for {station}. Height Difference set to 0") + else: + logging.error(f"No Station Data found for schedule. Height Difference set to 0") + return 0 + @classmethod def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): """ Constructs Schedule object from CSV file containing all trips of schedule. @@ -178,7 +202,7 @@ def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): - station_data[trip["departure_name"]]["elevation"] except (KeyError, TypeError): height_diff = 0 - trip["height_diff"] = height_diff + trip["height_difference"] = height_diff # Get level of loading from trips.csv or from file try: diff --git a/simba/simulate.py b/simba/simulate.py index a5965341..93356e73 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -92,7 +92,7 @@ def pre_simulation(args, data_container: DataContainer): schedule.init_soc_dispatcher(args) # each rotation is assigned a vehicle ID - schedule.assign_vehicles() + schedule.assign_vehicles(args) return schedule, args diff --git a/simba/trip.py b/simba/trip.py index b3c2abe6..6d3b7ea7 100644 --- a/simba/trip.py +++ b/simba/trip.py @@ -7,7 +7,7 @@ class Trip: consumption: simba.consumption.Consumption = None def __init__(self, rotation, departure_time, departure_name, - arrival_time, arrival_name, distance, temperature, level_of_loading, height_diff, + arrival_time, arrival_name, distance, temperature, level_of_loading, height_difference, **kwargs): self.departure_name = departure_name if type(departure_time) is str: @@ -20,7 +20,7 @@ def __init__(self, rotation, departure_time, departure_name, self.distance = float(distance) self.line = kwargs.get('line', None) self.temperature = float(temperature) - self.height_diff = height_diff + self.height_difference = height_difference self.level_of_loading = level_of_loading # mean speed in km/h from distance and travel time or from initialization # travel time is at least 1 min @@ -54,7 +54,7 @@ def calculate_consumption(self): self.rotation.vehicle_type, self.rotation.charging_type, temp=self.temperature, - height_diff=self.height_diff, + height_difference=self.height_difference, level_of_loading=self.level_of_loading, mean_speed=self.mean_speed) except AttributeError as e: diff --git a/tests/helpers.py b/tests/helpers.py index c499151a..b0a1fa59 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,5 +1,7 @@ """ Reusable functions that support tests """ +from argparse import Namespace + from simba import schedule, trip, consumption, util from simba.data_container import DataContainer @@ -21,12 +23,13 @@ def generate_basic_schedule(): "gc_power_deps": 1000, "cs_power_opps": 100, "cs_power_deps_depb": 50, - "cs_power_deps_oppb": 150 + "cs_power_deps_oppb": 150, + "desired_soc_deps": 1, } - generated_schedule = schedule.Schedule.from_csv(schedule_path, vehicle_types, station_path, **mandatory_args, - outside_temperature_over_day_path=temperature_path, - level_of_loading_over_day_path=lol_path) - generated_schedule.assign_vehicles() + generated_schedule = schedule.Schedule.from_csv( + schedule_path, vehicle_types, station_path, **mandatory_args, + outside_temperature_over_day_path=temperature_path, level_of_loading_over_day_path=lol_path) + generated_schedule.assign_vehicles(Namespace(**mandatory_args)) return generated_schedule diff --git a/tests/test_consumption.py b/tests/test_consumption.py index 9c86acbf..215f57e4 100644 --- a/tests/test_consumption.py +++ b/tests/test_consumption.py @@ -27,7 +27,7 @@ def test_calculate_consumption(self, tmp_path): # check distance scaling def calc_c(distance): return consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=10, height_diff=0, + time, distance, vehicle_type, charging_type, temp=10, height_difference=0, level_of_loading=0, mean_speed=18)[0] assert calc_c(dist) * 2 == calc_c(dist * 2) @@ -62,22 +62,22 @@ def true_cons(lol, incline, speed, t_amb): # Check various inputs, which need interpolation. Inputs have to be inside of the data, i.e. # not out of bounds assert true_cons(lol, incline, speed, t_amb) == consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, height_diff=incline * distance, + time, distance, vehicle_type, charging_type, temp=t_amb, height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] incline = 0.02 assert true_cons(lol, incline, speed, t_amb) == consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, height_diff=incline * distance, + time, distance, vehicle_type, charging_type, temp=t_amb, height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] t_amb = 15 assert true_cons(lol, incline, speed, t_amb) == consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, height_diff=incline * distance, + time, distance, vehicle_type, charging_type, temp=t_amb, height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] lol = 0.1 assert true_cons(lol, incline, speed, t_amb) == consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, height_diff=incline * distance, + time, distance, vehicle_type, charging_type, temp=t_amb, height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] # check for out of bounds consumption. Max consumption in the table is 6.6. @@ -86,12 +86,12 @@ def true_cons(lol, incline, speed, t_amb): lol = 99999 speed = 99999 assert consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, height_diff=incline * distance, + time, distance, vehicle_type, charging_type, temp=t_amb, height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] < 6.7 # check temperature default runs without errors when temp is None consumption.calculate_consumption( - time, dist, vehicle_type, charging_type, temp=None, height_diff=0, level_of_loading=0, + time, dist, vehicle_type, charging_type, temp=None, height_difference=0, level_of_loading=0, mean_speed=18)[0] # check temperature default from temperature time series error throwing @@ -100,13 +100,13 @@ def true_cons(lol, incline, speed, t_amb): time = datetime(year=2023, month=1, day=1, hour=last_hour + 2) with pytest.raises(KeyError): consumption.calculate_consumption( - time, dist, vehicle_type, charging_type, temp=None, height_diff=0, + time, dist, vehicle_type, charging_type, temp=None, height_difference=0, level_of_loading=0, mean_speed=18)[0] del consumption.temperatures_by_hour with pytest.raises(AttributeError): consumption.calculate_consumption( - time, dist, vehicle_type, charging_type, temp=None, height_diff=0, + time, dist, vehicle_type, charging_type, temp=None, height_difference=0, level_of_loading=0, mean_speed=18)[0] # reset temperature_by_hour @@ -118,10 +118,10 @@ def true_cons(lol, incline, speed, t_amb): time = datetime(year=2023, month=1, day=1, hour=last_hour + 2) with pytest.raises(KeyError): consumption.calculate_consumption( - time, dist, vehicle_type, charging_type, temp=20, height_diff=0, + time, dist, vehicle_type, charging_type, temp=20, height_difference=0, level_of_loading=None, mean_speed=18)[0] del consumption.lol_by_hour with pytest.raises(AttributeError): consumption.calculate_consumption( - time, dist, vehicle_type, charging_type, temp=20, height_diff=0, + time, dist, vehicle_type, charging_type, temp=20, height_difference=0, level_of_loading=None, mean_speed=18)[0] diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 918f83f5..b9245422 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -114,9 +114,6 @@ def test_assign_vehicles_fixed_recharge(self): initialize_consumption(self.vehicle_types) - default_schedule_arguments["path_to_csv"] = file_root / "trips_assign_vehicles.csv" - generated_schedule = schedule.Schedule.from_csv(**default_schedule_arguments) - generated_schedule.assign_vehicles() path_to_trips = file_root / "trips_assign_vehicles_extended.csv" generated_schedule = schedule.Schedule.from_csv( path_to_trips, self.vehicle_types, self.electrified_stations, **mandatory_args) @@ -360,7 +357,7 @@ def test_scenario_with_feed_in(self, default_schedule_arguments): assert scen.components.batteries["Station-0 storage"].capacity == 300 assert scen.components.batteries["Station-0 storage"].efficiency == 0.95 assert scen.components.batteries["Station-0 storage"].min_charging_power == 0 - generated_schedule.assign_vehicles() + generated_schedule.assign_vehicles(args) scen = generated_schedule.run(args) assert type(scen) is scenario.Scenario diff --git a/tests/test_soc_dispatcher.py b/tests/test_soc_dispatcher.py index 4444d87a..ca9a5e11 100644 --- a/tests/test_soc_dispatcher.py +++ b/tests/test_soc_dispatcher.py @@ -70,7 +70,7 @@ def basic_run(self): # Create soc dispatcher sched.init_soc_dispatcher(args) - sched.assign_vehicles() + sched.assign_vehicles(args) scen = sched.run(args) for rot in sched.rotations.values(): diff --git a/tests/test_station_optimization.py b/tests/test_station_optimization.py index 31b204fa..ac928fba 100644 --- a/tests/test_station_optimization.py +++ b/tests/test_station_optimization.py @@ -103,7 +103,7 @@ def basic_run(self, trips_file_name="trips.csv"): **vars(args2)) # Create soc dispatcher generated_schedule.init_soc_dispatcher(args) - generated_schedule.assign_vehicles() + generated_schedule.assign_vehicles(args) scen = generated_schedule.run(args) # optimization depends on vehicle_socs, therefore they need to be generated generate_soc_timeseries(scen) From df7fbc8f3d32ce1aa46c820c712051d208ae3729 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Mon, 27 May 2024 15:34:04 +0200 Subject: [PATCH 10/68] Make flake8 happy --- simba/data_container.py | 11 +++++++---- simba/optimization.py | 3 ++- simba/report.py | 4 +++- simba/schedule.py | 16 +++++++--------- simba/trip.py | 4 ++-- simba/util.py | 5 +++-- tests/test_consumption.py | 24 ++++++++++++------------ tests/test_schedule.py | 1 - 8 files changed, 36 insertions(+), 32 deletions(-) diff --git a/simba/data_container.py b/simba/data_container.py index d82384c7..f9858c7c 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -39,7 +39,6 @@ def add_vehicle_types_from_json(self, file_path: Path): self.add_vehicle_types(vehicle_types) return self - def add_consumption_data_from_vehicle_type_linked_files(self): assert self.vehicle_types_data, "No vehicle_type data in the data_container" mileages = list(get_values_from_nested_key("mileage", self.vehicle_types_data)) @@ -52,7 +51,7 @@ def add_consumption_data_from_vehicle_type_linked_files(self): self.add_consumption_data(mileage_path, df) return self - def add_consumption_data(self, data_name, df: pd.DataFrame) -> None: + def add_consumption_data(self, data_name, df: pd.DataFrame) -> 'DataContainer': """Add consumption data to the data container. Consumption data will be used by the Consumption instance @@ -61,7 +60,10 @@ def add_consumption_data(self, data_name, df: pd.DataFrame) -> None: :param data_name: name of the data, linked with vehicle_type :type data_name: str :param df: dataframe with consumption data and various expected columns - :type df: pd.DataFrame""" + :type df: pd.DataFrame + :return: DatacContainer instance with added consumption data + """ + for expected_col in [INCLINE, T_AMB, LEVEL_OF_LOADING, SPEED, CONSUMPTION]: assert expected_col in df.columns, f"Consumption data is missing {expected_col}" assert data_name not in self.consumption_data, f"{data_name} already exists in data" @@ -96,6 +98,7 @@ def get_values_from_nested_key(key, data: dict) -> list: if isinstance(value, dict): yield from get_values_from_nested_key(key, value) + def get_dict_from_csv(column, file_path, index): output = dict() with open(file_path, "r") as f: @@ -103,4 +106,4 @@ def get_dict_from_csv(column, file_path, index): reader = csv.DictReader(f, delimiter=delim) for row in reader: output[float(row[index])] = float(row[column]) - return output \ No newline at end of file + return output diff --git a/simba/optimization.py b/simba/optimization.py index 711dd5e0..1273ac13 100644 --- a/simba/optimization.py +++ b/simba/optimization.py @@ -293,7 +293,8 @@ def recombination(schedule, args, trips, depot_trips): depot_trip = generate_depot_trip_data_dict( trip.departure_name, depot_trips, args.default_depot_distance, args.default_mean_speed) - height_difference = schedule.get_height_difference(depot_trip["name"],trip.departure_name) + height_difference = schedule.get_height_difference( + depot_trip["name"], trip.departure_name) rotation.add_trip({ "departure_time": trip.departure_time - depot_trip["travel_time"], "departure_name": depot_trip["name"], diff --git a/simba/report.py b/simba/report.py index cb13d302..deb7d7f6 100644 --- a/simba/report.py +++ b/simba/report.py @@ -6,9 +6,11 @@ import matplotlib.pyplot as plt import matplotlib -matplotlib.use('Agg') from spice_ev.report import aggregate_global_results, plot, generate_reports +matplotlib.use('Agg') + + def open_for_csv(filepath): """ Create a file handle to write to. diff --git a/simba/schedule.py b/simba/schedule.py index 6025907f..1c5c6d64 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -7,15 +7,15 @@ import warnings from typing import Dict, Type, Iterable -from spice_ev.scenario import Scenario -import spice_ev.util as spice_ev_util - import simba.rotation -from simba import util from simba.data_container import DataContainer from simba import util, optimizer_util from simba.rotation import Rotation +from spice_ev.components import VehicleType +from spice_ev.scenario import Scenario +import spice_ev.util as spice_ev_util + class SocDispatcher: """Dispatches the right initial SoC for every vehicle id at scenario generation. @@ -41,9 +41,6 @@ def get_soc(self, vehicle_id: str, trip: "simba.trip.Trip", station_type: str = return v_socs[trip] except KeyError: return vars(self).get("default_soc_" + station_type) -from spice_ev.components import VehicleType -from spice_ev.scenario import Scenario -import spice_ev.util as spice_ev_util class Schedule: @@ -111,7 +108,7 @@ def get_height_difference(self, departure_name, arrival_name): except KeyError: logging.error(f"No elevation data found for {station}. Height Difference set to 0") else: - logging.error(f"No Station Data found for schedule. Height Difference set to 0") + logging.error("No Station Data found for schedule. Height Difference set to 0") return 0 @classmethod @@ -126,6 +123,7 @@ def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): :type stations: string :param kwargs: Command line arguments :type kwargs: dict + :raises NotImplementedError: if stations is neither a str,Path nor dictionary. :return: Returns a new instance of Schedule with all trips from csv loaded. :rtype: Schedule """ @@ -139,7 +137,7 @@ def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): else: raise NotImplementedError - schedule = cls(vehicle_types, stations_dict , **kwargs) + schedule = cls(vehicle_types, stations_dict, **kwargs) station_data = dict() station_path = kwargs.get("station_data_path") diff --git a/simba/trip.py b/simba/trip.py index 6d3b7ea7..df94804d 100644 --- a/simba/trip.py +++ b/simba/trip.py @@ -7,8 +7,8 @@ class Trip: consumption: simba.consumption.Consumption = None def __init__(self, rotation, departure_time, departure_name, - arrival_time, arrival_name, distance, temperature, level_of_loading, height_difference, - **kwargs): + arrival_time, arrival_name, distance, temperature, level_of_loading, + height_difference, **kwargs): self.departure_name = departure_name if type(departure_time) is str: departure_time = datetime.fromisoformat(departure_time) diff --git a/simba/util.py b/simba/util.py index bd5b5a2c..55e4c1f1 100644 --- a/simba/util.py +++ b/simba/util.py @@ -250,6 +250,7 @@ def setup_logging(args, time_str): ) logging.captureWarnings(True) + def get_args(): parser = get_parser() @@ -279,7 +280,6 @@ def mutate_args_for_spiceev(args): args.PRICE_THRESHOLD = -100 # ignore price for charging decisions - def get_parser(): parser = argparse.ArgumentParser( description='SimBA - Simulation toolbox for Bus Applications.') @@ -434,6 +434,7 @@ def get_parser(): parser.add_argument('--config', help='Use config file to set arguments') return parser + def daterange(start_date, end_date, time_delta): """ Iterate over a datetime range using a time_delta step. @@ -449,4 +450,4 @@ def daterange(start_date, end_date, time_delta): """ while start_date < end_date: yield start_date - start_date += time_delta \ No newline at end of file + start_date += time_delta diff --git a/tests/test_consumption.py b/tests/test_consumption.py index 215f57e4..24d1451c 100644 --- a/tests/test_consumption.py +++ b/tests/test_consumption.py @@ -62,23 +62,23 @@ def true_cons(lol, incline, speed, t_amb): # Check various inputs, which need interpolation. Inputs have to be inside of the data, i.e. # not out of bounds assert true_cons(lol, incline, speed, t_amb) == consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, height_difference=incline * distance, - level_of_loading=lol, mean_speed=speed)[0] + time, distance, vehicle_type, charging_type, temp=t_amb, + height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] incline = 0.02 assert true_cons(lol, incline, speed, t_amb) == consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, height_difference=incline * distance, - level_of_loading=lol, mean_speed=speed)[0] + time, distance, vehicle_type, charging_type, temp=t_amb, + height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] t_amb = 15 assert true_cons(lol, incline, speed, t_amb) == consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, height_difference=incline * distance, - level_of_loading=lol, mean_speed=speed)[0] + time, distance, vehicle_type, charging_type, temp=t_amb, + height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] lol = 0.1 assert true_cons(lol, incline, speed, t_amb) == consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, height_difference=incline * distance, - level_of_loading=lol, mean_speed=speed)[0] + time, distance, vehicle_type, charging_type, temp=t_amb, + height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] # check for out of bounds consumption. Max consumption in the table is 6.6. t_amb = 99999 @@ -86,13 +86,13 @@ def true_cons(lol, incline, speed, t_amb): lol = 99999 speed = 99999 assert consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, height_difference=incline * distance, - level_of_loading=lol, mean_speed=speed)[0] < 6.7 + time, distance, vehicle_type, charging_type, temp=t_amb, + height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] < 6.7 # check temperature default runs without errors when temp is None consumption.calculate_consumption( - time, dist, vehicle_type, charging_type, temp=None, height_difference=0, level_of_loading=0, - mean_speed=18)[0] + time, dist, vehicle_type, charging_type, temp=None, height_difference=0, + level_of_loading=0, mean_speed=18)[0] # check temperature default from temperature time series error throwing last_hour = 12 diff --git a/tests/test_schedule.py b/tests/test_schedule.py index b9245422..c1093fe8 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -533,7 +533,6 @@ def test_generate_price_lists(self): generated_schedule.init_soc_dispatcher(args) - # only test individual price CSV and random price generation args.include_price_csv = None # Station-0: all options From 37d49b1a152a6c54bb0dde7fe7806dd0bb4239d1 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Mon, 27 May 2024 16:29:56 +0200 Subject: [PATCH 11/68] Add several data types to datacontainer --- simba/data_container.py | 133 +++++++++++++++++++++++++++++++++++++++- simba/schedule.py | 121 ++++++++++++++++++++++++++++-------- simba/simulate.py | 29 +++------ 3 files changed, 236 insertions(+), 47 deletions(-) diff --git a/simba/data_container.py b/simba/data_container.py index f9858c7c..396a51e4 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -1,5 +1,7 @@ """Module to handle data access, by the varying SimBA modules.""" import csv +import logging +import datetime from pathlib import Path from typing import Dict @@ -16,20 +18,147 @@ def __init__(self): self.consumption_data: Dict[str, pd.DataFrame] = {} self.temperature_data: Dict[int, float] = {} self.level_of_loading_data: Dict[int, float] = {} + self.stations_data: Dict[str, dict] = {} + self.cost_parameters_data: Dict[str, dict] = {} + self.station_geo_data: Dict[str, dict] = {} - # def add_temperature_data_from_csv(self, ): - # to be implemented + self.trip_data: [dict] = [] + + def add_trip_data_from_csv(self, file_path: Path) -> 'DataContainer': + """ Add trip data from csv file to DataContainer""" + + trips = [] + with open(file_path, 'r', encoding='utf-8') as trips_file: + trip_reader = csv.DictReader(trips_file) + for trip in trip_reader: + trip_d = dict(trip) + trip_d["arrival_time"] = datetime.datetime.fromisoformat(trip["arrival_time"]) + trip_d["departure_time"] = datetime.datetime.fromisoformat(trip["departure_time"]) + trips.append(trip_d) + + + + def add_station_geo_data(self, data: dict) -> None: + """Add station_geo data to the data container. + + Used when adding station_geo to a data container from any source + :param data: data containing station_geo + :type data: dict + """ + self.station_geo_data = data + + def add_station_geo_data_from_csv(self, file_path: Path) -> 'DataContainer': + # find the temperature and elevation of the stations by reading the .csv file. + # this data is stored in the schedule and passed to the trips, which use the information + # for consumption calculation. Missing station data is handled with default values. + try: + with open(file_path, "r", encoding='utf-8') as f: + delim = util.get_csv_delim(file_path) + reader = csv.DictReader(f, delimiter=delim) + for row in reader: + file_path.update({str(row['Endhaltestelle']): { + "elevation": float(row['elevation']), "lat": float(row.get('lat', 0)), + "long": float(row.get('long', 0))} + }) + except FileNotFoundError or KeyError: + logging.warning("Warning: external csv file '{}' not found or not named properly " + "(Needed column names are 'Endhaltestelle' and 'elevation')". + format(file_path), + stacklevel=100) + except ValueError: + logging.warning("Warning: external csv file '{}' does not contain numeric " + "values in the column 'elevation'. Station data is discarded.". + format(file_path), + stacklevel=100) + + return self + + def add_level_of_loading_data(self, data: dict) -> None: + """Add level_of_loading data to the data container. + + Used when adding level_of_loading to a data container from any source + :param data: data containing level_of_loading + :type data: dict + """ + self.level_of_loading_data = data + + def add_level_of_loading_data_from_csv(self, file_path: Path) -> 'DataContainer': + index = "hour" + column = "level_of_loading" + level_of_loading_data_dict = get_dict_from_csv(column, file_path, index) + self.add_level_of_loading_data(level_of_loading_data_dict) + return self + + def add_temperature_data(self, data: dict) -> None: + """Add temperature data to the data container. + + Used when adding temperature to a data container from any source + :param data: data containing temperature + :type data: dict + """ + self.cost_parameters_data = data + + def add_temperature_data_from_csv(self, file_path: Path) -> 'DataContainer': + index = "hour" + column = "temperature" + temperature_data_dict = get_dict_from_csv(column, file_path, index) + self.add_temperature_data(temperature_data_dict) + return self + + def add_cost_parameters(self, data: dict) -> None: + """Add cost_parameters data to the data container. cost_parameters will be stored in the + args instance + + Used when adding cost_parameters to a data container from any source + :param data: data containing cost_parameters + :type data: dict + """ + self.cost_parameters = data + + def add_cost_parameters_from_json(self, file_path: Path) -> 'DataContainer': + """ Get json data from a file_path""" + try: + with open(file_path, encoding='utf-8') as f: + cost_parameters = util.uncomment_json_file(f) + except FileNotFoundError: + raise Exception(f"Path to cost parameters ({file_path}) " + "does not exist. Exiting...") + self.add_cost_parameters(cost_parameters) + return self + + def add_stations(self, data: dict) -> None: + """Add station data to the data container. Stations will be stored in the + Schedule instance + + Used when adding stations to a data container from any source + :param data: data containing stations + :type data: dict + """ + self.stations_data = data + + def add_stations_from_json(self, file_path: Path) -> 'DataContainer': + """ Get json data from a file_path""" + try: + with open(file_path, encoding='utf-8') as f: + stations = util.uncomment_json_file(f) + except FileNotFoundError: + raise Exception(f"Path to electrified stations ({file_path}) " + "does not exist. Exiting...") + self.add_stations(stations) + return self def add_vehicle_types(self, data: dict) -> None: """Add vehicle_type data to the data container. Vehicle_types will be stored in the Schedule instance + Used when adding new vehicle types to a data container from any source :param data: data containing vehicle_types :type data: dict """ self.vehicle_types_data = data def add_vehicle_types_from_json(self, file_path: Path): + """ Get json data from a file_path""" try: with open(file_path, encoding='utf-8') as f: vehicle_types = util.uncomment_json_file(f) diff --git a/simba/schedule.py b/simba/schedule.py index 1c5c6d64..73e7a76d 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -83,33 +83,80 @@ def __init__(self, vehicle_types, stations, **kwargs): for opt in mandatory_options: setattr(self, opt, kwargs.get(opt)) - # @classmethod - # def from_container(self, data_container: DataContainer): - # to be implemented + @classmethod + def from_datacontainer(cls, data_container: DataContainer, args): - def get_height_difference(self, departure_name, arrival_name): - """ Get the height difference of two stations. + schedule = cls(data_container.vehicle_types_data, data_container.stations_data, **args) + schedule.station_data = data_container.station_geo_data - Defaults to 0 if height data is not found - :param departure_name: Departure station - :type departure_name: str - :param arrival_name: Arrival station - :type arrival_name: str - :return: Height difference - :rtype: float - """ - if isinstance(self.station_data, dict): - station = departure_name + for trip in data_container.trip_data: + rotation_id = trip['rotation_id'] + # trip gets reference to station data and calculates height diff during trip + # initialization. Could also get the height difference from here on + # get average hour of trip if level of loading or temperature has to be read from + # auxiliary tabular data + + # get average hour of trip and parse to string, since tabular data has strings + # as keys + hour = (trip["departure_time"] + + (trip["arrival_time"] - trip["departure_time"]) / 2).hour + # Get height difference from station_data + + trip["height_difference"] = schedule.get_height_difference( + trip["departure_name", trip["arrival_name"]]) + + # Get level of loading from trips.csv or from file try: - start_height = self.station_data[station]["elevation"] - station = arrival_name - end_height = self.station_data[arrival_name]["elevation"] - return end_height - start_height - except KeyError: - logging.error(f"No elevation data found for {station}. Height Difference set to 0") - else: - logging.error("No Station Data found for schedule. Height Difference set to 0") - return 0 + # Clip level of loading to [0,1] + lol = max(0, min(float(trip["level_of_loading"]), 1)) + # In case of empty temperature column or no column at all + except (KeyError, ValueError): + lol = data_container.level_of_loading_data[hour] + + trip["level_of_loading"] = lol + + # Get temperature from trips.csv or from file + try: + # Cast temperature to float + temperature = float(trip["temperature"]) + # In case of empty temperature column or no column at all + except (KeyError, ValueError): + temperature = data_container.temperature_data[hour] + trip["temperature"] = temperature + if rotation_id not in schedule.rotations.keys(): + schedule.rotations.update({ + rotation_id: Rotation(id=rotation_id, + vehicle_type=trip['vehicle_type'], + schedule=schedule)}) + schedule.rotations[rotation_id].add_trip(trip) + + # set charging type for all rotations without explicitly specified charging type. + # charging type may have been set above if a trip of a rotation has a specified + # charging type + for rot in schedule.rotations.values(): + if rot.charging_type is None: + rot.set_charging_type(ct=args.get('preferred_charging_type', 'oppb')) + + if args.get("check_rotation_consistency"): + # check rotation expectations + inconsistent_rotations = cls.check_consistency(schedule) + if inconsistent_rotations: + # write errors to file + filepath = args["output_directory"] / "inconsistent_rotations.csv" + with open(filepath, "w", encoding='utf-8') as f: + for rot_id, e in inconsistent_rotations.items(): + f.write(f"Rotation {rot_id}: {e}\n") + logging.error(f"Rotation {rot_id}: {e}") + if args.get("skip_inconsistent_rotations"): + # remove this rotation from schedule + del schedule.rotations[rot_id] + elif args.get("skip_inconsistent_rotations"): + logging.warning("Option skip_inconsistent_rotations ignored, " + "as check_rotation_consistency is not set to 'true'") + + return schedule + + @classmethod def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): @@ -263,7 +310,7 @@ def check_consistency(cls, schedule): - trips have positive times between departure and arrival :param schedule: the schedule to check - :type schedule: dict + :type schedule: Schedule :return: inconsistent rotations. Dict of rotation ID -> error message :rtype: dict """ @@ -740,6 +787,30 @@ def get_common_stations(self, only_opps=True): break return rot_set + def get_height_difference(self, departure_name, arrival_name): + """ Get the height difference of two stations. + + Defaults to 0 if height data is not found + :param departure_name: Departure station + :type departure_name: str + :param arrival_name: Arrival station + :type arrival_name: str + :return: Height difference + :rtype: float + """ + if isinstance(self.station_data, dict): + station = departure_name + try: + start_height = self.station_data[station]["elevation"] + station = arrival_name + end_height = self.station_data[arrival_name]["elevation"] + return end_height - start_height + except KeyError: + logging.error(f"No elevation data found for {station}. Height Difference set to 0") + else: + logging.error("No Station Data found for schedule. Height Difference set to 0") + return 0 + def get_negative_rotations(self, scenario): """ Get rotations with negative SoC from SpiceEV outputs. diff --git a/simba/simulate.py b/simba/simulate.py index 93356e73..523aac27 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -36,8 +36,16 @@ def create_and_fill_data_container(args): data_container = DataContainer() # Add the vehicle_types from a json file data_container.add_vehicle_types_from_json(args.vehicle_types_path) + # Add consumption data, which is found in the vehicle_type data data_container.add_consumption_data_from_vehicle_type_linked_files() + + # Add station data + data_container.add_stations_from_json(args.stations_path) + + # Add cost_parameters_data + data_container.add_cost_parameters_from_json(args.cost_parameters_file) + return data_container @@ -57,30 +65,11 @@ def pre_simulation(args, data_container: DataContainer): """ # Deepcopy args so original args do not get mutated, i.e. deleted args = deepcopy(args) - - # load stations file - try: - with open(args.electrified_stations, encoding='utf-8') as f: - stations = util.uncomment_json_file(f) - except FileNotFoundError: - raise Exception(f"Path to electrified stations ({args.electrified_stations}) " - "does not exist. Exiting...") - - # load cost parameters - if args.cost_parameters_file is not None: - try: - with open(args.cost_parameters_file, encoding='utf-8') as f: - args.cost_parameters = util.uncomment_json_file(f) - except FileNotFoundError: - raise Exception(f"Path to cost parameters ({args.cost_parameters_file}) " - "does not exist. Exiting...") - # Add consumption calculator to trip class Trip.consumption = data_container.to_consumption() # generate schedule from csv - schedule = Schedule.from_csv(args.input_schedule, data_container.vehicle_types_data, stations, - **vars(args)) + schedule = Schedule.from_datacontainer(data_container, args) # filter rotations schedule.rotation_filter(args) From 2b0bda1b274f26b49f079375d0dbdf4d1821dd1a Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Wed, 29 May 2024 11:31:56 +0200 Subject: [PATCH 12/68] Update trips for opimizer --- simba/optimizer_util.py | 164 ++++++--- simba/rotation.py | 17 +- simba/station_optimization.py | 40 +- simba/station_optimizer.py | 342 +++++++++++------- .../optimization/trips_for_optimizer.csv | 2 +- 5 files changed, 359 insertions(+), 206 deletions(-) diff --git a/simba/optimizer_util.py b/simba/optimizer_util.py index 8f7ebc1d..1d8ac86d 100644 --- a/simba/optimizer_util.py +++ b/simba/optimizer_util.py @@ -65,12 +65,15 @@ class OptimizerConfig: """ Class for the configuration file """ def __init__(self): - self.debug_level = None - self.console_level = None - self.exclusion_rots = None - self.exclusion_stations = None - self.inclusion_stations = None - self.standard_opp_station = None + self.logger_name = "" + self.debug_level = 0 + self.console_level = 99 + + self.exclusion_rots = [] + self.exclusion_stations = set() + self.inclusion_stations = set() + self.standard_opp_station = {"type": "opps", "n_charging_stations": None} + self.schedule = None self.scenario = None self.args = None @@ -78,28 +81,34 @@ def __init__(self): self.battery_capacity = None self.charging_curve = None self.charging_power = None - self.min_soc = None - self.solver = None - self.rebase_scenario = None - self.pickle_rebased = None + self.min_soc = 0 + + self.solver = "spiceev" + self.rebase_scenario = False + self.pickle_rebased = False + # used for gradual scenario analysis in django-simba + self.early_return = False + self.pickle_rebased_name = None - self.opt_type = None - self.remove_impossible_rotations = None - self.node_choice = None - self.max_brute_loop = None - self.run_only_neg = None - self.run_only_oppb = None - self.estimation_threshold = None - self.check_for_must_stations = None + self.opt_type = "greedy" + self.eps = 0.0001 + + self.remove_impossible_rotations = False + self.node_choice = "step-by-step" + self.max_brute_loop = 20 + self.run_only_neg = True + self.run_only_oppb = True + self.estimation_threshold = 0.8 + self.check_for_must_stations = False + self.pruning_threshold = 3 + self.decision_tree_path = None - self.save_decision_tree = None + self.save_decision_tree = False self.optimizer_output_dir = None - self.reduce_rotations = None + self.reduce_rotations = False self.rotations = None self.path = None - self.pruning_threshold = None self.save_all_results = None - self.eps = None def time_it(function, timers={}): @@ -270,7 +279,7 @@ def get_index_by_time(scenario, search_time): return (search_time - scenario.start_time) // scenario.interval -def get_rotation_soc_util(rot_id, schedule, scenario, soc_data: dict = None): +def get_rotation_soc(rot_id, schedule, scenario, soc_data: dict = None): """ Return the SoC time series with start and end index for a given rotation ID. :param rot_id: rotation_id @@ -475,15 +484,20 @@ def get_groups_from_events(events, impossible_stations=None, could_not_be_electr break else: if optimizer: - optimizer.logger.warning( - 'Did not find rotation %s in any subset of possible electrifiable stations', - event.rotation.id) + optimizer.logger.warning(f"Rotation {event.rotation.id} has no possible " + "electrifiable stations and will be removed.") # this event will not show up in an event_group. # therefore it needs to be put into this set could_not_be_electrified.update([event.rotation.id]) groups = list(zip(event_groups, station_subsets)) - return sorted(groups, key=lambda x: len(x[1])) + # each event group should have events and stations. If not something went wrong. + filtered_groups = list(filter(lambda x: len(x[0]) != 0 and len(x[1]) != 0, groups)) + if len(filtered_groups) != len(groups): + if optimizer: + optimizer.logger.error("An event group has no possible electrifiable stations and " + "will not be optimized.") + return sorted(filtered_groups, key=lambda x: len(x[1])) def join_all_subsets(subsets): @@ -494,34 +508,74 @@ def join_all_subsets(subsets): :return: joined subsets if they connect with other subsets in some way :rtype: list(set) """ - joined_subset = True - while joined_subset: - joined_subset, subsets = join_subsets(subsets) - return subsets - -def join_subsets(subsets: typing.Iterable[set]): - """ Run through subsets and return their union, if they have an intersection. - - Run through every subset and check with every other subset if there is an intersection - If an intersection is found, the subsets are joined and returned with a boolean of True. - If no intersection is found over all subsets False is returned which will cancel the outer - call in join_all_subsets - - :param subsets: sets to be joined - :type subsets: iterable - :return: status if joining subsets is finished and the current list of connected subsets - :rtype: (bool,list(set)) - """ - subsets = [s.copy() for s in subsets] - for i in range(len(subsets)): - for ii in range(i+1, len(subsets)): - intersec = subsets[i].intersection(subsets[ii]) - if len(intersec) > 0: - subsets[i] = subsets[i].union(subsets[ii]) - subsets.remove(subsets[ii]) - return True, subsets - return False, subsets + # create set of all stations in given subsets + all_stations = {station for subset in subsets for station in subset} + # make list from station set to have fixed order + all_stations_list = list(all_stations) + + # create look-up-table from station name to index in all_stations_list + all_stations_index = dict() + for i, station in enumerate(all_stations_list): + all_stations_index[station] = i + + # station_array: boolean matrix of dimension n*m with n being the number of unique stations and + # m the amount of subsets. If a station is part of the subset it will be set to True + # this will turn the subsets + # Sub_s_0={Station4} + # Sub_s_1={Station0} + # Sub_s_2={Station1} + # Sub_s_3={Station0,Station2} + # Sub_s_4={Station3} + # into + # Sub_s_0 Sub_s_1 Sub_s_2 Sub_s_3 Sub_s_4 + # Station0 False True False True False + # Station1 False False True False False + # Station2 False False False True False + # Station3 False False False False True + # Station4 True False False False False + station_array = np.zeros((len(all_stations), len(subsets))).astype(bool) + for i, subset in enumerate(subsets): + for station in subset: + station_array[all_stations_index[station], i] = True + + # Traverse the matrix row wise station by station. + # Columns which share a True value in this row are merged to a single one. + # All rows (!) of these columns are merged into a new subset. + # This translates to subsets sharing the same station. + + # The result cannot contain any overlapping columns / sets + # Sub_s_0 Sub_s_1 Sub_s_2 Sub_s_3 Sub_s_4 + # Station0 False True False True False + + # returns the indicies 1 and 3. the columns are merged. + # Sub_s_0 Sub_s_1/Sub_s_3 Sub_s_2 Sub_s_4 + # Station0 False True False False + # Station1 False False True False + # Station2 False True False False + # Station3 False False False True + # Station4 True False False False + + # The other rows are compared as well but share no True values and are not changed. + # Afterwards, all rows contain only a single True value. These are the final merged subsets. + # In the example, Sub_s_0 contains Station4, Sub_s_1/3 contains Station0 and Station2 and so on. + rows = station_array.shape[0] + for row in range(rows): + indicies = np.where(station_array[row, :])[0] + if len(indicies) > 1: + station_array[:, indicies[0]] = np.sum(station_array[:, indicies], axis=1).astype(bool) + station_array = np.delete(station_array, indicies[1:], axis=1) + + # Translate the matrix back to subsets + columns = station_array.shape[1] + subsets = [] + for column in range(columns): + subset = set() + indicies = np.where(station_array[:, column])[0] + for ind in indicies: + subset.add(all_stations_list[ind]) + subsets.append(subset) + return subsets def toolbox_from_pickle(sched_name, scen_name, args_name): @@ -833,7 +887,7 @@ def plot_rot(rot_id, sched, scen, axis=None, rot_only=True): :return: axis of the plot :rtype: matplotlib.axes """ - soc, start, end = get_rotation_soc_util(rot_id, sched, scen) + soc, start, end = get_rotation_soc(rot_id, sched, scen) if not rot_only: start = 0 end = -1 diff --git a/simba/rotation.py b/simba/rotation.py index e3e944e8..be59afbe 100644 --- a/simba/rotation.py +++ b/simba/rotation.py @@ -24,6 +24,10 @@ def __init__(self, id, vehicle_type, schedule) -> None: self.arrival_time = None self.arrival_name = None + # Tracks if a warning has been logged if the rotation ends at a non-electrified station. + # Further warnings will be turned off. + self.logged_warning = False + def add_trip(self, trip): """ Create a trip object and append to rotations trip set. @@ -123,7 +127,8 @@ def earliest_departure_next_rot(self): def min_standing_time(self): """Minimum duration of standing time in minutes. - No consideration of depot buffer time or charging curve. + No consideration of depot buffer time or charging curve + :return: Minimum duration of standing time in minutes. """ # noqa: DAR201 @@ -136,9 +141,13 @@ def min_standing_time(self): charge_power = stations[self.arrival_name].get( f"cs_power_deps_{ct}", vars(self.schedule)[f"cs_power_deps_{ct}"]) except KeyError: - logging.warning(f"Rotation {self.id} ends at a non-electrified station.") - # min_standing_time set to zero, so if another rotation starts here, - # the vehicle can always be used. + # log a warning once for this. Since min_standing_time is called many times during + # vehicle assignment, this would clutter the console / log. + if not self.logged_warning: + self.logged_warning = True + logging.warning(f"Rotation {self.id} ends at a non-electrified station.") + # min_standing_time set to zero, so if another rotation starts here, + # the vehicle can always be used. return 0 capacity = self.schedule.vehicle_types[self.vehicle_type][ct]["capacity"] diff --git a/simba/station_optimization.py b/simba/station_optimization.py index e9b4e890..66c9412a 100644 --- a/simba/station_optimization.py +++ b/simba/station_optimization.py @@ -50,6 +50,8 @@ def setup_logger(conf): stream_handler = logging.StreamHandler() stream_handler.setFormatter(formatter) stream_handler.setLevel(conf.console_level) + + # Log to an optimization-specific file, a general file and to console this_logger.addHandler(file_handler_this_opt) this_logger.addHandler(file_handler_all_opts) this_logger.addHandler(stream_handler) @@ -140,6 +142,10 @@ def run_optimization(conf, sched=None, scen=None, args=None): # remove none values from socs in the vehicle_socs optimizer.replace_socs_from_none_to_value() + # Remove already electrified stations from possible stations + optimizer.not_possible_stations = set(optimizer.electrified_stations.keys()).union( + optimizer.not_possible_stations) + # all stations electrified: are there still negative rotations? if conf.remove_impossible_rotations: neg_rots = optimizer.get_negative_rotations_all_electrified() @@ -148,10 +154,10 @@ def run_optimization(conf, sched=None, scen=None, args=None): r: optimizer.schedule.rotations[r] for r in optimizer.schedule.rotations if r not in optimizer.config.exclusion_rots} - logger.warning( - "%s negative rotations %s were removed from schedule", len(neg_rots), neg_rots) - assert len(optimizer.schedule.rotations) > 0, ("Schedule can not be optimized, since " - "rotations can not be electrified.") + logger.warning(f"{len(neg_rots)} negative rotations {neg_rots} were removed from schedule " + "because they cannot be electrified") + assert len(optimizer.schedule.rotations) > 0, ( + "Schedule cannot be optimized, since rotations cannot be electrified.") # if the whole network can not be fully electrified if even just a single station is not # electrified, this station must be included in a fully electrified network @@ -161,8 +167,18 @@ def run_optimization(conf, sched=None, scen=None, args=None): must_stations = optimizer.get_critical_stations_and_rebase(relative_soc=False) logger.warning("%s must stations %s", len(must_stations), must_stations) + # Store the rotations to be optimized. optimizer.loop() will mutate the dictionary but not the + # rotations itself. + rotations_for_opt = optimizer.schedule.rotations.copy() + logger.log(msg="Starting greedy station optimization", level=100) + + # start a timer to later check how long the optimization took + opt_util.get_time() + + # Go into the optimization loop, where stations are subsequently electrified ele_stations, ele_station_set = optimizer.loop() + ele_station_set = ele_station_set.union(must_include_set) logger.debug("%s electrified stations : %s", len(ele_station_set), ele_station_set) logger.debug("%s total stations", len(ele_stations)) @@ -171,10 +187,14 @@ def run_optimization(conf, sched=None, scen=None, args=None): # remove none values from socs in the vehicle_socs so timeseries_calc can work optimizer.replace_socs_from_none_to_value() - vehicle_socs = optimizer.timeseries_calc() + # Restore the rotations and the scenario which where the goal of optimization. + optimizer.schedule.rotations = rotations_for_opt + optimizer.scenario = optimizer.base_scenario - new_events = optimizer.get_low_soc_events(soc_data=vehicle_socs) + # Check if the rotations which were part of the optimization are not negative anymore + vehicle_socs = optimizer.timeseries_calc(optimizer.electrified_station_set) + new_events = optimizer.get_low_soc_events(soc_data=vehicle_socs) if len(new_events) > 0: logger.debug("Estimation of network still shows negative rotations") for event in new_events: @@ -188,7 +208,7 @@ def run_optimization(conf, sched=None, scen=None, args=None): json.dump(output_dict, file, ensure_ascii=False, indent=2) # Calculation with SpiceEV is more accurate and will show if the optimization is viable or not - logger.debug("Detailed calculation of optimized case as a complete scenario") + logger.debug("Detailed calculation of an optimized case as a complete scenario") # Restore original rotations for rotation_id in original_schedule.rotations: @@ -198,9 +218,9 @@ def run_optimization(conf, sched=None, scen=None, args=None): optimizer.config.exclusion_rots = set() _, __ = optimizer.preprocessing_scenario( electrified_stations=ele_stations, run_only_neg=False) - - logger.warning("Still negative rotations: %s", - optimizer.schedule.get_negative_rotations(optimizer.scenario)) + neg_rotations = optimizer.schedule.get_negative_rotations(optimizer.scenario) + if len(neg_rotations) > 0: + logger.log(msg=f"Still {len(neg_rotations)} negative rotations: {neg_rotations}", level=100) logger.log(msg="Station optimization finished after " + opt_util.get_time(), level=100) return optimizer.schedule, optimizer.scenario diff --git a/simba/station_optimizer.py b/simba/station_optimizer.py index a168c730..ac540b86 100644 --- a/simba/station_optimizer.py +++ b/simba/station_optimizer.py @@ -3,14 +3,15 @@ import logging import pickle from copy import deepcopy, copy -from datetime import datetime, timedelta +from datetime import datetime from pathlib import Path +from typing import Iterable + import numpy as np import simba.optimizer_util as opt_util from spice_ev import scenario from simba import rotation, schedule -from simba.util import uncomment_json_file class StationOptimizer: @@ -32,8 +33,7 @@ def __init__(self, sched: schedule.Schedule, scen: scenario.Scenario, args, self.logger = logger self.config = config self.electrified_station_set = set() - with open(self.args.electrified_stations, "r", encoding="utf-8", ) as file: - self.electrified_stations = uncomment_json_file(file) + self.electrified_stations = deepcopy(self.schedule.stations) self.base_stations = self.electrified_stations.copy() self.base_electrified_station_set = set() @@ -57,8 +57,8 @@ def loop(self, **kwargs): node_choice = kwargs.get("node_choice", self.config.node_choice) opt_type = kwargs.get("opt_type", self.config.opt_type) - - self.scenario.vehicle_socs = self.timeseries_calc(electrify_stations=self.must_include_set) + electrified_station = self.must_include_set.union(self.electrified_station_set) + self.scenario.vehicle_socs = self.timeseries_calc(electrified_station) self.base_scenario = deepcopy(self.scenario) self.base_schedule = copy(self.schedule) @@ -77,6 +77,15 @@ def loop(self, **kwargs): base_events, self.not_possible_stations, could_not_be_electrified=self.could_not_be_electrified, optimizer=self) + if len(groups) == 0: + self.logger.info("The scenario is already optimized, as it has no low SoC.") + return self.electrified_stations, self.electrified_station_set + + # sort groups by highest potential of a single electrification. Used if partial + # electrification is relevant + groups = sorted(groups, key=lambda group: opt_util.evaluate( + group[0], self, soc_data=self.scenario.vehicle_socs)[0][1]) + # storage for the sets of stations which will be generated list_greedy_sets = [set()] * len(groups) @@ -86,7 +95,6 @@ def loop(self, **kwargs): # baseline greedy optimization # base line is created simply by not having a decision tree and not a pre optimized_set yet - self.logger.debug(opt_util.get_time()) for group_nr, group in enumerate(groups[:]): # unpack the group events, stations = group @@ -94,9 +102,10 @@ def loop(self, **kwargs): self.current_tree = self.decision_trees[group_nr] lines = {lne for e in events for lne in e.rotation.lines} self.logger.warning( - "Optimizing %s out of %s. This includes these Lines", group_nr + 1, len(groups)) - self.logger.warning(lines) - self.logger.warning("%s events with %s stations", (len(events)), len(stations)) + "Optimizing %s out of %s groups. This includes these Lines: %s", + group_nr + 1, len(groups), lines) + self.logger.warning("%s low soc events with %s potential stations", + (len(events)), len(stations)) # the electrified_station_set get mutated by the group optimization # the results are stored in the list of greedy sets, but before each group optimization @@ -217,7 +226,7 @@ def loop(self, **kwargs): for stat in single_set: self.electrify_station(stat, self.electrified_station_set) # dump the measured running times of the functions - self.logger.debug(opt_util.time_it(None)) + self.logger.debug("Durations of Optimization: ", opt_util.time_it(None)) return self.electrified_stations, self.electrified_station_set def get_negative_rotations_all_electrified(self, rel_soc=False): @@ -233,7 +242,7 @@ def get_negative_rotations_all_electrified(self, rel_soc=False): stats = {stat for event in events for stat in event.stations_list if stat not in self.not_possible_stations} electrified_station_set = set(stats) - vehicle_socs = self.timeseries_calc(ele_station_set=electrified_station_set) + vehicle_socs = self.timeseries_calc(electrified_station_set) new_events = self.get_low_soc_events(soc_data=vehicle_socs) return {event.rotation.id for event in new_events} @@ -335,12 +344,12 @@ def group_optimization(self, group, choose_station_function, track_not_possible_ # quick calculation has to electrify everything in one step, that is chronologically. # or the lifting of socs is not correct. # Since its only adding charge on top of soc time series, electrification that took - # place before in the the optimization but later in the time series in regards to the + # place before in the optimization but later in the time series in regards to the # current electrification would show to much charge, since charging curves decrease over # soc. self.deepcopy_socs() - self.scenario.vehicle_socs = self.timeseries_calc( - event_rotations, electrify_stations=best_station_ids) + electrified_stations = self.electrified_station_set.union(best_station_ids) + self.scenario.vehicle_socs = self.timeseries_calc(electrified_stations, event_rotations) else: self.schedule.rotations = rotation_dict self.schedule, self.scenario = opt_util.run_schedule( @@ -356,9 +365,12 @@ def group_optimization(self, group, choose_station_function, track_not_possible_ delta_energy = opt_util.get_missing_energy(new_events, self.config.min_soc) events_remaining[0] -= len(event_group) - len(new_events) + + new_rotation = {event.rotation for event in new_events} + r_electrified = len(event_rotations_ids) - len(new_rotation) self.logger.debug( - "Last electrification electrified %s/%s. %s remaining events in the base group.", - len(event_group) - len(new_events), len(event_group), events_remaining[0]) + f"Last electrification electrified {r_electrified}/{len(event_rotations_ids)} " + f"Rotations. {events_remaining[0]} remaining events in the base group.") delta_base_energy = delta_energy # put this node into the decision tree including the missing energy @@ -399,8 +411,9 @@ def group_optimization(self, group, choose_station_function, track_not_possible_ if len(pre_optimized_set) - len(self.electrified_station_set) < thresh: self.copy_scen_sched() self.deepcopy_socs() + electrified_stations = self.electrified_station_set.union(best_station_ids) self.scenario.vehicle_socs = self.timeseries_calc( - event_rotations, electrify_stations=best_station_ids) + electrified_stations, event_rotations) prune_events = self.get_low_soc_events( rotations=event_rotations_ids, rel_soc=True, **kwargs) station_eval = opt_util.evaluate(prune_events, self) @@ -437,102 +450,100 @@ def sort_station_events(self, charge_events_single_station): return sorted(charge_events_single_station, key=lambda x: x.arrival_time) @opt_util.time_it - def timeseries_calc( - self, rotations=None, soc_dict=None, ele_station_set=None, soc_upper_threshold=None, - electrify_stations=None) -> object: - """ A quick estimation of socs for after electrifying stations. - - The function assumes unlimited charging points per electrified station. - - :param rotations: Optional if not optimizer.schedule.rotations should be used - :type rotations: iterable - :param soc_dict: Optional if not optimizer.scenario.vehicle_socs should be used - :type soc_dict: dict - :param ele_station_set: Stations which are electrified . Default None leads to using the + def timeseries_calc(self, electrified_stations: set, rotations=None) -> dict: + """ A quick estimation of socs. + + Iterates through rotations and calculates the soc. + The start value is assumed to be desired_soc_deps. + The function subtracts the consumption of each trip, + and numerically estimates the charge at the electrified station. + Charging powers depend on the soc_charge_curve_dict, which were earlier created using the + vehicle charge curve and the schedule.cs_power_opps. + + :param electrified_stations: stations which are calculated as electrified with cs_power_opps + :type electrified_stations: set + :param rotations: Rotations to be calculated. Defaults to optimizer.schedule.rotations + :type rotations: iterable[Rotation] + :param electrified_stations: Stations which are electrified. Default None leads to using the so far optimized optimizer.electrified_station_set - :type ele_station_set: set - :param soc_upper_threshold: Optional upper threshold for the soc. Default value is - config.desired_soc_deps. This value clips charging so no socs above this value are - reached - :type soc_upper_threshold: float - :param electrify_stations: stations to be electrified - :type electrify_stations: set(str) :return: Returns soc dict with lifted socs :rtype dict() """ if rotations is None: rotations = self.schedule.rotations.values() - if soc_dict is None: - soc_dict = self.scenario.vehicle_socs - if ele_station_set is None: - ele_station_set = self.electrified_station_set - if not soc_upper_threshold: - soc_upper_threshold = self.args.desired_soc_deps - if electrify_stations is None: - electrify_stations = set() - - if electrify_stations is None: - electrify_stations = set() - ele_stations = {*ele_station_set, *electrify_stations} - soc_dict = deepcopy(soc_dict) - + vehicle_socs = deepcopy(self.scenario.vehicle_socs) for rot in rotations: ch_type = (rot.vehicle_id.find("oppb") > 0) * "oppb" + ( rot.vehicle_id.find("depb") > 0) * "depb" v_type = rot.vehicle_id.split("_" + ch_type)[0] + capacity = self.schedule.vehicle_types[v_type][ch_type]["capacity"] soc_over_time_curve = self.soc_charge_curve_dict[v_type][ch_type] - soc = np.array(soc_dict[rot.vehicle_id]) + soc = vehicle_socs[rot.vehicle_id] + last_soc = self.args.desired_soc_deps for i, trip in enumerate(rot.trips): - if trip.arrival_name not in ele_stations: - continue + # Handle consumption during trip + # Find start, end and delta_soc of the current trip + idx_start = opt_util.get_index_by_time(self.scenario, trip.departure_time) + idx_end = opt_util.get_index_by_time(self.scenario, trip.arrival_time) + delta_idx = idx_end + 1 - idx_start + d_soc = trip.consumption / capacity + + # Linear interpolation of SoC during trip + soc[idx_start:idx_end + 1] = np.linspace(last_soc, last_soc - d_soc, + delta_idx) + + # Update last known SoC with current value + last_soc = last_soc - d_soc + + # Fill the values while the vehicle is standing waiting for the next trip idx = opt_util.get_index_by_time(self.scenario, trip.arrival_time) try: - standing_time_min = opt_util.get_charging_time( - trip, rot.trips[i + 1], self.args) + idx_end = opt_util.get_index_by_time(self.scenario, + rot.trips[i + 1].departure_time) except IndexError: - standing_time_min = 0 + # No next trip. Rotation finished. + break + # Set SoC at arrival time for whole standing time + soc[idx:idx_end+1] = last_soc + + if trip.arrival_name not in electrified_stations: + # Arrival station is not electrified: skip station + continue + + # Arrival station is not the last station of the rotation and electrified. + # Calculate the charge at this station + + # Get the standing time in minutes. Buffer time is already subtracted + standing_time_min = opt_util.get_charging_time( + trip, rot.trips[i + 1], self.args) + + # Assume that the start soc for charging is at least 0, since this results in the + # largest possible charge for a monotonous decreasing charge curve in the SoC range + # of [0%,100%]. search_soc = max(0, soc[idx]) - # get the soc lift - d_soc = opt_util.get_delta_soc( - soc_over_time_curve, search_soc, standing_time_min) - # clip the soc lift to the desired_soc_deps, which is the maximum that can be - # reached when the rotation stays positive + + # Get the soc lift + d_soc = opt_util.get_delta_soc(soc_over_time_curve, search_soc, standing_time_min) + + # Clip the SoC lift to the desired_soc_deps, + # which is the maximum that can reached when the rotation stays positive d_soc = min(d_soc, self.args.desired_soc_opps) - buffer_idx = int( - (opt_util.get_buffer_time(trip, self.args.default_buffer_time_opps)) - / timedelta(minutes=1)) + + # Add the charge as linear interpolation during the charge time, but only start + # after the buffer time + buffer_idx = (int(opt_util.get_buffer_time( + trip, self.args.default_buffer_time_opps).total_seconds()/60)) delta_idx = int(standing_time_min) + 1 - old_soc = soc[idx + buffer_idx:idx + buffer_idx + delta_idx].copy() - soc[idx + buffer_idx:] += d_soc - soc[idx + buffer_idx:idx + buffer_idx + delta_idx] = old_soc soc[idx + buffer_idx:idx + buffer_idx + delta_idx] += np.linspace(0, d_soc, delta_idx) + # Keep track of the last SoC as starting point for the next trip + last_soc = soc[idx + buffer_idx + delta_idx-1] - soc_pre = soc[:idx] - soc = soc[idx:] - soc_max = np.max(soc) - while soc_max > soc_upper_threshold: - # descending array - desc = np.arange(len(soc), 0, -1) - # gradient of soc i.e. positive if charging negative if discharging - diff = np.hstack((np.diff(soc), -1)) - # masking of socs >1 and negative gradient for local maximum - # i.e. after lifting the soc, it finds the first spot where the soc is bigger - # than the upper threshold and descending. - idc_loc_max = np.argmax(desc * (soc > 1) * (diff < 0)) - - # find the soc value of this local maximum - soc_max = soc[idc_loc_max] - # reducing everything after local maximum - soc[idc_loc_max:] = soc[idc_loc_max:] - (soc_max - 1) - - # capping everything before local maximum - soc[:idc_loc_max][soc[:idc_loc_max] > 1] = soc_upper_threshold - soc_max = np.max(soc) - soc = np.hstack((soc_pre, soc)) - soc_dict[rot.vehicle_id] = soc - return soc_dict + start_idx = opt_util.get_index_by_time(self.scenario, rot.trips[0].departure_time) + end_idx = opt_util.get_index_by_time(self.scenario, rot.trips[-1].arrival_time) + vehicle_socs[rot.vehicle_id][start_idx:end_idx+1] = soc[start_idx:end_idx+1] + return vehicle_socs @opt_util.time_it def expand_tree(self, station_eval): @@ -859,10 +870,10 @@ def get_critical_stations_and_rebase(self, relative_soc=False): Get the stations that must be electrified for full electrification of the system and put them into the electrified stations and an extra set of critical stations. - Electrify every station but one. If without this single station there are below zero soc + Electrify every station but one. If without this single station there are below zero SoC events it is a critical station. - :param relative_soc: should the evaluation use the relative or absolute soc + :param relative_soc: should the evaluation use the relative or absolute SoC :param relative_soc: bool :return: Group of stations which have to be part of a fully electrified system :rtype: set(str) @@ -880,8 +891,8 @@ def get_critical_stations_and_rebase(self, relative_soc=False): level=100) for station in sorted(electrified_station_set_all): electrified_station_set = electrified_station_set_all.difference([station]) - - vehicle_socs = self.timeseries_calc(electrify_stations=electrified_station_set) + electrified_stations = self.electrified_station_set.union(electrified_station_set) + vehicle_socs = self.timeseries_calc(electrified_stations) soc_min = 1 for rot in self.schedule.rotations: soc, start, end = self.get_rotation_soc(rot, vehicle_socs) @@ -900,9 +911,9 @@ def get_critical_stations_and_rebase(self, relative_soc=False): return critical_stations def replace_socs_from_none_to_value(self): - """ Removes soc values of None by filling them with the last value which is not None. + """ Removes SoC values of None by filling them with the last value which is not None. - The function only changes None values which are at the end of soc time series. Data type + The function only changes None values which are at the end of SoC time series. Data type is switched to np.array for easier handling at later stages. """ # make sure no None values exist in SOCs. Fill later values with last value @@ -919,13 +930,13 @@ def replace_socs_from_none_to_value(self): self.scenario.vehicle_socs[v_id] = soc def get_rotation_soc(self, rot_id, soc_data: dict = None): - """ Gets the soc object with start and end index for a given rotation id. + """ Gets the SoC object with start and end index for a given rotation id. :param rot_id: rotation_id :param soc_data: optional soc_data if not the scenario data should be used - :return: tuple with soc array, start index and end index + :return: tuple with SoC array, start index and end index """ - return opt_util.get_rotation_soc_util( + return opt_util.get_rotation_soc( rot_id, self.schedule, self.scenario, soc_data=soc_data) def get_index_by_time(self, search_time: datetime): @@ -981,77 +992,76 @@ def get_time_by_index(self, idx): return searched_time @opt_util.time_it - def get_low_soc_events(self, rotations=None, filter_standing_time=True, + def get_low_soc_events(self, rotations: Iterable = None, filter_standing_time=True, rel_soc=False, soc_data=None, **kwargs): - """ Return low soc events below the config threshold. + """ Return low SoC events below the config threshold. - :param rotations: rotations to be searched for low soc events. Default None means whole + :param rotations: rotation_ids to be searched for low SoC events. Default None means whole schedule is searched - :type rotations: iterable + :type rotations: Iterable :param filter_standing_time: Should the stations be filtered by standing time. True leads to an output with only stations with charging potential :type filter_standing_time: bool - :param rel_soc: Defines if the start soc should be seen as full even when not. - i.e. a drop from a rotation start soc from 0.1 to -0.3 is not critical since the start - soc from the rotation will be raised + :param rel_soc: Defines if the start SoC should be seen as full even when not. + i.e. a drop from a rotation start SoC from 0.1 to -0.3 is not critical since the start + SoC from the rotation will be raised If the rel_soc is false, it means coupled rotations might be prone to errors due to impossible lifts. - :param soc_data: soc data to be used. Default None means the soc data from the optimizer + :param soc_data: SoC data to be used. Default None means the SoC data from the optimizer scenario is used :type soc_data: dict :param kwargs: optional soc_lower_thresh or soc_upper_thresh if from optimizer differing values should be used :raises InfiniteLoopException: If while loop does not change during iterations - :return: low soc events + :return: low SoC events :rtype: list(simba.optimizer_util.LowSocEvent) """ if not rotations: rotations = self.schedule.rotations soc_lower_thresh = kwargs.get("soc_lower_thresh", self.config.min_soc) - soc_upper_thresh = kwargs.get("soc_upper_thresh", self.args.desired_soc_deps) - # create list of events which describe trips which end in a soc below zero - # the event is bound by the lowest soc and an upper soc threshold which is naturally 1 + soc_upper_thresh = kwargs.get("soc_upper_thresh", self.args.desired_soc_opps) + # create list of events which describe trips which end in a SoC below zero + # the event is bound by the lowest SoC and an upper SoC threshold which is naturally 1 # properties before and after these points have no effect on the event itself, similar to # an event horizon events = [] for rot_id in rotations: + events_per_rotation = 0 rot = self.schedule.rotations[rot_id] soc, rot_start_idx, rot_end_idx = self.get_rotation_soc(rot_id, soc_data) rot_end_idx += 1 idx = range(0, len(soc)) - # combined data of the soc data of the rotation and the original index - # The array will get masked later, so the index of the array might be different + # if rotation gets a start SoC below the args.desired_soc_deps, this should change + # below 0 SoC events, since fixing the rotation before leads to fixing this rotation. + # If using relative SOC, SOC lookup has to be adjusted + if rel_soc: + soc = np.array(soc) + # if the rotation starts with lower than desired soc, lift the soc + if soc[rot_start_idx] < self.args.desired_soc_deps: + soc = self.lift_and_clip_positive_gradient(rot_start_idx, soc, soc_upper_thresh) + + # combined data of the SoC data of the rotation and the original index + # the array will get masked later, so the index of the array might be different # to the index data column soc_idx = np.array((soc, idx))[:, rot_start_idx:rot_end_idx] # Mask for soc_idx which is used to know if an index has been checked or not mask = np.ones(len(soc_idx[0])).astype(bool) - # get the minimum soc and index of this value. The mask does nothing yet + # get the minimum SoC and index of this value. The mask does nothing yet min_soc, min_idx = get_min_soc_and_index(soc_idx, mask) - soc_lower_thresh_cur = soc_lower_thresh - # if rotation gets a start soc below 1 this should change below 0 soc events, - # since fixing the rotation before would lead to fixing this rotation - - # if using relative SOC, SOC lookup has to be adjusted - if rel_soc: - start_soc = soc_idx[0, 0] - soc_lower_thresh_cur = min(start_soc, soc_upper_thresh) - ( - soc_upper_thresh - soc_lower_thresh) - soc_upper_thresh = soc_lower_thresh_cur + soc_upper_thresh - # Used to check if an infinite loop is happening old_idx = -1 - # while the minimal soc of the soc time series is below the threshold find the events + # while the minimal SoC of the SoC time series is below the threshold find the events # which are connected with this event. Every iteration the data of the found events is # removed. At some point the reduced time series is either empty or does not have a - # soc below the lower threshold. - while min_soc < soc_lower_thresh_cur: + # SoC below the lower threshold. + while min_soc < soc_lower_thresh: # soc_idx is of type float, so the index needs to be cast to int min_idx = int(min_idx) @@ -1061,10 +1071,10 @@ def get_low_soc_events(self, rotations=None, filter_standing_time=True, old_idx = min_idx i = min_idx - # find the first index by going back from the minimal soc index, where the soc + # find the first index by going back from the minimal SoC index, where the soc # was above the upper threshold OR the index where the rotation started. while soc[i] < soc_upper_thresh: - if i == rot_start_idx: + if i <= rot_start_idx: break i -= 1 @@ -1116,9 +1126,14 @@ def get_low_soc_events(self, rotations=None, filter_standing_time=True, v_type=v_type, ch_type=ch_type) events.append(event) + events_per_rotation += 1 + + if events_per_rotation > len(rot.trips): + self.logger.error("More low-soc-events than trips found for rotation " + f"{rot.id} with vehicle {rot.vehicle_id}") # the mask is expanded to the just checked low_soc_event - mask[start-rot_start_idx:end-rot_start_idx+1] = False + mask[start - rot_start_idx:end - rot_start_idx + 1] = False # if the mask does not leave any value, the loop is finished if not np.any(mask): @@ -1128,15 +1143,70 @@ def get_low_soc_events(self, rotations=None, filter_standing_time=True, return events + def lift_and_clip_positive_gradient(self, start_idx: int, soc: np.array, + soc_upper_thresh: float): + """ Lift a SoC array to a given soc_upper_thresh and handle clipping + + Lifts a given SoC array to the value of soc_upper_thresh from the start_index to the end. + Values which exceed soc_upper_thresh and have a positive gradient are clipped to + soc_upper_thresh. If values exist which exceed soc_upper_thresh and have negative gradients, + they and the following socs have this delta subtracted instead. This is the behavior of + approximate behaviour of charging a vehicle above 100%. Values can not exceed 100%. As soon + as discharge happens, the value drops. + + :param start_idx: index from where the array is lifted + :type start_idx: int + :param soc: array which contains socs to be lifted + :type soc: np.array + :param soc_upper_thresh: max value of socs used for clipping + :type soc_upper_thresh: float + :return: None + + """ + + delta = self.args.desired_soc_deps - soc[start_idx] + # lift all beyond the start index (including the start_idx) + soc[start_idx:] += delta + # values before the start index stay unaffected + soc_pre = soc[:start_idx] + + # to speed up computation, only look at the affected part + soc = soc[start_idx:] + soc_max = np.max(soc) + # Clip soc values to soc_upper_thresh. + # Multiple local maxima above this value can exist which, + # if they are rising over time, need multiple clippings. + while soc_max > soc_upper_thresh: + # descending array from len(soc) to 0 + desc = np.arange(len(soc), 0, -1) + # gradient of SoC i.e. positive if charging, negative if discharging + diff = np.hstack((np.diff(soc), -1)) + # masking of socs >1 and negative gradient for local maximum + # i.e. after lifting the soc, it finds the first spot where the SoC is bigger + # than the upper threshold and descending. + idc_loc_max = np.argmax(desc * (soc > 1) * (diff < 0)) + + # find the SoC value of this local maximum + soc_max = soc[idc_loc_max] + # reducing everything after local maximum + soc[idc_loc_max:] = soc[idc_loc_max:] - (soc_max - soc_upper_thresh) + + # clip everything before local maximum + soc[:idc_loc_max][soc[:idc_loc_max] > 1] = soc_upper_thresh + soc_max = np.max(soc) + # restore SoC with unaffected part + soc = np.hstack((soc_pre, soc)) + return soc + def get_min_soc_and_index(soc_idx, mask): - """ Returns the minimal soc and the corresponding index of a masked soc_idx. + """ Returns the minimal SoC and the corresponding index of a masked soc_idx. - :param soc_idx: soc values and their corresponding index of shape (2,nr_values). + :param soc_idx: SoC values and their corresponding index of shape (2,nr_values). :type soc_idx: np.array :param mask: boolean mask. It is false where the soc_idx has been checked already :type mask: np.array - :return: minimal soc and corresponding index + :return: minimal SoC and corresponding index :rtype: tuple(Float, Float) """ return soc_idx[:, mask][:, np.argmin(soc_idx[0, mask])] diff --git a/tests/test_input_files/optimization/trips_for_optimizer.csv b/tests/test_input_files/optimization/trips_for_optimizer.csv index cac505c5..70262ab7 100644 --- a/tests/test_input_files/optimization/trips_for_optimizer.csv +++ b/tests/test_input_files/optimization/trips_for_optimizer.csv @@ -4,7 +4,7 @@ LINE_0,Station-1,2022-03-07 21:41:00,2022-03-07 22:04:00,Station-2,1000,1,AB LINE_0,Station-2,2022-03-07 22:08:00,2022-03-07 22:43:00,Station-3,2000,1,AB LINE_0,Station-3,2022-03-07 22:51:00,2022-03-07 23:24:00,Station-4,1000,1,AB Aussetzfahrt,Station-4,2022-03-07 23:28:00,2022-03-08 00:03:00,Station-0,500,1,AB -Einsetzfahrt,Station-0,2022-03-07 21:31:00,2022-03-07 21:32:00,Station-1,100,2,AB +Einsetzfahrt,Station-0,2022-03-07 21:31:00,2022-03-07 21:35:00,Station-1,100,2,AB LINE_1,Station-1,2022-03-07 21:41:00,2022-03-07 22:04:00,Station-3,1000,2,AB LINE_1,Station-3,2022-03-07 22:51:00,2022-03-07 23:24:00,Station-4,800,2,AB Aussetzfahrt,Station-4,2022-03-07 23:28:00,2022-03-08 00:03:00,Station-0,500,2,AB \ No newline at end of file From 8e981cf4d2af694823d3d6fc7cf3557951bf1242 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Wed, 29 May 2024 16:53:59 +0200 Subject: [PATCH 13/68] Fix Tests --- data/examples/simba.cfg | 4 +- docs/source/simulation_parameters.rst | 2 +- simba/consumption.py | 13 ++++ simba/costs.py | 2 +- simba/data_container.py | 85 ++++++++++++++++++++------- simba/schedule.py | 42 ++++++------- simba/simulate.py | 23 ++------ simba/trip.py | 9 +-- simba/util.py | 6 +- tests/test_schedule.py | 5 +- tests/test_simulate.py | 30 ++++++++-- tests/test_soc_dispatcher.py | 5 +- 12 files changed, 141 insertions(+), 85 deletions(-) diff --git a/data/examples/simba.cfg b/data/examples/simba.cfg index d367c77b..6bba817f 100644 --- a/data/examples/simba.cfg +++ b/data/examples/simba.cfg @@ -7,7 +7,7 @@ input_schedule = data/examples/trips_example.csv # Deactivate storage of output by setting output_directory = null output_directory = data/output/ # Electrified stations (required) -electrified_stations = data/examples/electrified_stations.json +electrified_stations_path = data/examples/electrified_stations.json # Vehicle types (defaults to: ./data/examples/vehicle_types.json) vehicle_types_path = data/examples/vehicle_types.json # Path to station data with stations heights @@ -22,7 +22,7 @@ level_of_loading_over_day_path = data/examples/default_level_of_loading_over_da # Path to configuration file for the station optimization. Only needed for mode "station_optimization" optimizer_config = data/examples/default_optimizer.cfg # Cost parameters (needed if cost_calculation flag is set to true, see Flag section below) -cost_parameters_file = data/examples/cost_params.json +cost_parameters_path = data/examples/cost_params.json # Path to rotation filter rotation_filter = data/examples/rotation_filter.csv diff --git a/docs/source/simulation_parameters.rst b/docs/source/simulation_parameters.rst index de8189c0..b515d737 100644 --- a/docs/source/simulation_parameters.rst +++ b/docs/source/simulation_parameters.rst @@ -36,7 +36,7 @@ The example (data/simba.cfg) contains parameter descriptions which are explained - Data/sim_outputs - Path as string - Output files are stored here; set to null to deactivate - * - electrified_stations + * - electrified_stations_path - ./data/examples/vehicle_types.json - Path as string - Path to Electrified stations data diff --git a/simba/consumption.py b/simba/consumption.py index dbfba0d2..63a589a9 100644 --- a/simba/consumption.py +++ b/simba/consumption.py @@ -3,6 +3,7 @@ import pandas as pd from simba import util +from simba.data_container import DataContainer from simba.ids import INCLINE, LEVEL_OF_LOADING, SPEED, T_AMB, CONSUMPTION, VEHICLE_TYPE @@ -102,6 +103,18 @@ def calculate_consumption(self, time, distance, vehicle_type, charging_type, tem return consumed_energy, delta_soc + @staticmethod + def create_from_data_container(data_container: DataContainer): + """Build a consumption instance from the stored data in the data container. + :returns: Consumption instance + """ + # setup consumption calculator that can be accessed by all trips + consumption = Consumption(data_container.vehicle_types_data) + for name, df in data_container.consumption_data.items(): + consumption.set_consumption_interpolation(name, df) + return consumption + + def set_consumption_interpolation(self, consumption_lookup_name: str, df: pd.DataFrame): """ Set interpolation function for consumption lookup. diff --git a/simba/costs.py b/simba/costs.py index 3e96e0c8..9025010b 100644 --- a/simba/costs.py +++ b/simba/costs.py @@ -317,7 +317,7 @@ def set_electricity_costs(self): power_v2g_feed_in_list=timeseries.get("V2G feed-in [kW]"), power_battery_feed_in_list=timeseries.get("battery feed-in [kW]"), charging_signal_list=timeseries.get("window signal [-]"), - price_sheet_path=self.args.cost_parameters_file, + price_sheet_path=self.args.cost_parameters_path, grid_operator=gc.grid_operator, power_pv_nominal=pv, ) diff --git a/simba/data_container.py b/simba/data_container.py index 396a51e4..b89c38f9 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -1,4 +1,5 @@ """Module to handle data access, by the varying SimBA modules.""" +import argparse import csv import logging import datetime @@ -8,7 +9,6 @@ import pandas as pd from simba import util -from simba.consumption import Consumption from simba.ids import INCLINE, LEVEL_OF_LOADING, SPEED, T_AMB, CONSUMPTION @@ -24,17 +24,65 @@ def __init__(self): self.trip_data: [dict] = [] + def fill_with_args(self, args: argparse.Namespace): + return self.fill_with_paths(args.vehicle_types_path, + args.electrified_stations_path, + args.cost_parameters_path, + args.input_schedule, + args.outside_temperature_over_day_path, + args.level_of_loading_over_day_path, + args.station_data_path, + ) + + def fill_with_paths(self, + vehicle_types_path, + electrified_stations_path, + cost_parameters_file, + trips_file_path, + outside_temperature_over_day_path, + level_of_loading_over_day_path, + station_data_path=None, + ): + # Add the vehicle_types from a json file + self.add_vehicle_types_from_json(vehicle_types_path) + + # Add consumption data, which is found in the vehicle_type data + self.add_consumption_data_from_vehicle_type_linked_files() + + if outside_temperature_over_day_path is not None: + self.add_temperature_data_from_csv(outside_temperature_over_day_path) + + if level_of_loading_over_day_path is not None: + self.add_level_of_loading_data_from_csv(level_of_loading_over_day_path) + + # Add electrified_stations data + self.add_stations_from_json(electrified_stations_path) + + # Add station geo data + if station_data_path is not None: + self.add_station_geo_data_from_csv(station_data_path) + + # Add cost_parameters_data + self.add_cost_parameters_from_json(cost_parameters_file) + + self.add_trip_data_from_csv(trips_file_path) + return self + def add_trip_data_from_csv(self, file_path: Path) -> 'DataContainer': """ Add trip data from csv file to DataContainer""" - trips = [] + self.trip_data = [] with open(file_path, 'r', encoding='utf-8') as trips_file: trip_reader = csv.DictReader(trips_file) for trip in trip_reader: trip_d = dict(trip) trip_d["arrival_time"] = datetime.datetime.fromisoformat(trip["arrival_time"]) trip_d["departure_time"] = datetime.datetime.fromisoformat(trip["departure_time"]) - trips.append(trip_d) + trip_d["level_of_loading"] = cast_float_or_none(trip.get("level_of_loading")) + trip_d["temperature"] = cast_float_or_none(trip.get("temperature")) + trip_d["distance"] = float(trip["distance"]) + self.trip_data.append(trip_d) + @@ -51,15 +99,17 @@ def add_station_geo_data_from_csv(self, file_path: Path) -> 'DataContainer': # find the temperature and elevation of the stations by reading the .csv file. # this data is stored in the schedule and passed to the trips, which use the information # for consumption calculation. Missing station data is handled with default values. + self.station_geo_data = dict() try: with open(file_path, "r", encoding='utf-8') as f: delim = util.get_csv_delim(file_path) reader = csv.DictReader(f, delimiter=delim) for row in reader: - file_path.update({str(row['Endhaltestelle']): { - "elevation": float(row['elevation']), "lat": float(row.get('lat', 0)), - "long": float(row.get('long', 0))} - }) + self.station_geo_data[str(row['Endhaltestelle'])]= { + "elevation": float(row['elevation']), + "lat": float(row.get('lat', 0)), + "long": float(row.get('long', 0)), + } except FileNotFoundError or KeyError: logging.warning("Warning: external csv file '{}' not found or not named properly " "(Needed column names are 'Endhaltestelle' and 'elevation')". @@ -96,7 +146,7 @@ def add_temperature_data(self, data: dict) -> None: :param data: data containing temperature :type data: dict """ - self.cost_parameters_data = data + self.temperature_data = data def add_temperature_data_from_csv(self, file_path: Path) -> 'DataContainer': index = "hour" @@ -113,7 +163,7 @@ def add_cost_parameters(self, data: dict) -> None: :param data: data containing cost_parameters :type data: dict """ - self.cost_parameters = data + self.cost_parameters_data = data def add_cost_parameters_from_json(self, file_path: Path) -> 'DataContainer': """ Get json data from a file_path""" @@ -200,17 +250,6 @@ def add_consumption_data(self, data_name, df: pd.DataFrame) -> 'DataContainer': return self - def to_consumption(self) -> Consumption: - """Build a consumption instance from the stored data - :returns: Consumption instance - """ - # setup consumption calculator that can be accessed by all trips - consumption = Consumption(self.vehicle_types_data) - for name, df in self.consumption_data.items(): - consumption.set_consumption_interpolation(name, df) - return consumption - - def get_values_from_nested_key(key, data: dict) -> list: """Get all the values of the specified key in a nested dict @@ -236,3 +275,9 @@ def get_dict_from_csv(column, file_path, index): for row in reader: output[float(row[index])] = float(row[column]) return output + +def cast_float_or_none(val: any) -> any: + try: + return float(val) + except (ValueError, TypeError): + return None \ No newline at end of file diff --git a/simba/schedule.py b/simba/schedule.py index 73e7a76d..9050f6da 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -86,7 +86,7 @@ def __init__(self, vehicle_types, stations, **kwargs): @classmethod def from_datacontainer(cls, data_container: DataContainer, args): - schedule = cls(data_container.vehicle_types_data, data_container.stations_data, **args) + schedule = cls(data_container.vehicle_types_data, data_container.stations_data, **vars(args)) schedule.station_data = data_container.station_geo_data for trip in data_container.trip_data: @@ -103,26 +103,18 @@ def from_datacontainer(cls, data_container: DataContainer, args): # Get height difference from station_data trip["height_difference"] = schedule.get_height_difference( - trip["departure_name", trip["arrival_name"]]) + trip["departure_name"], trip["arrival_name"]) - # Get level of loading from trips.csv or from file - try: - # Clip level of loading to [0,1] - lol = max(0, min(float(trip["level_of_loading"]), 1)) - # In case of empty temperature column or no column at all - except (KeyError, ValueError): - lol = data_container.level_of_loading_data[hour] + if trip["level_of_loading"] is None: + trip["level_of_loading"] = data_container.level_of_loading_data.get(hour) + else: + if not 0 <= trip["level_of_loading"] <= 1: + logging.warning("Level of loading is out of range [0,1] and will be clipped.") + trip["level_of_loading"] = min(1, max(0, trip["level_of_loading"])) - trip["level_of_loading"] = lol + if trip["temperature"] is None: + trip["temperature"] = data_container.temperature_data.get(hour) - # Get temperature from trips.csv or from file - try: - # Cast temperature to float - temperature = float(trip["temperature"]) - # In case of empty temperature column or no column at all - except (KeyError, ValueError): - temperature = data_container.temperature_data[hour] - trip["temperature"] = temperature if rotation_id not in schedule.rotations.keys(): schedule.rotations.update({ rotation_id: Rotation(id=rotation_id, @@ -135,22 +127,22 @@ def from_datacontainer(cls, data_container: DataContainer, args): # charging type for rot in schedule.rotations.values(): if rot.charging_type is None: - rot.set_charging_type(ct=args.get('preferred_charging_type', 'oppb')) + rot.set_charging_type(ct=vars(args).get('preferred_charging_type', 'oppb')) - if args.get("check_rotation_consistency"): + if vars(args).get("check_rotation_consistency"): # check rotation expectations inconsistent_rotations = cls.check_consistency(schedule) if inconsistent_rotations: # write errors to file - filepath = args["output_directory"] / "inconsistent_rotations.csv" + filepath = args.output_directory / "inconsistent_rotations.csv" with open(filepath, "w", encoding='utf-8') as f: for rot_id, e in inconsistent_rotations.items(): f.write(f"Rotation {rot_id}: {e}\n") logging.error(f"Rotation {rot_id}: {e}") - if args.get("skip_inconsistent_rotations"): + if vars(args).get("skip_inconsistent_rotations"): # remove this rotation from schedule del schedule.rotations[rot_id] - elif args.get("skip_inconsistent_rotations"): + elif vars(args).get("skip_inconsistent_rotations"): logging.warning("Option skip_inconsistent_rotations ignored, " "as check_rotation_consistency is not set to 'true'") @@ -258,6 +250,10 @@ def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): lol = level_of_loading_dict[hour] trip["level_of_loading"] = lol + trip["distance"] = float(trip["distance"]) + trip["arrival_time"] = datetime.datetime.fromisoformat(trip["arrival_time"]) + trip["departure_time"] = datetime.datetime.fromisoformat(trip["departure_time"]) + # Get temperature from trips.csv or from file try: # Cast temperature to float diff --git a/simba/simulate.py b/simba/simulate.py index 523aac27..28123103 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -2,7 +2,8 @@ import traceback from copy import deepcopy -from simba import report, optimization, util +from simba import report, optimization, util, consumption +from simba.consumption import Consumption from simba.data_container import DataContainer from simba.costs import calculate_costs from simba.optimizer_util import read_config as read_optimizer_config @@ -24,7 +25,7 @@ def simulate(args): :rtype: tuple """ # The data_container stores various input data. - data_container = create_and_fill_data_container(args) + data_container = DataContainer().fill_with_args(args) schedule, args = pre_simulation(args, data_container) scenario = schedule.run(args) @@ -32,22 +33,6 @@ def simulate(args): return schedule, scenario -def create_and_fill_data_container(args): - data_container = DataContainer() - # Add the vehicle_types from a json file - data_container.add_vehicle_types_from_json(args.vehicle_types_path) - - # Add consumption data, which is found in the vehicle_type data - data_container.add_consumption_data_from_vehicle_type_linked_files() - - # Add station data - data_container.add_stations_from_json(args.stations_path) - - # Add cost_parameters_data - data_container.add_cost_parameters_from_json(args.cost_parameters_file) - - return data_container - def pre_simulation(args, data_container: DataContainer): """ @@ -66,7 +51,7 @@ def pre_simulation(args, data_container: DataContainer): # Deepcopy args so original args do not get mutated, i.e. deleted args = deepcopy(args) # Add consumption calculator to trip class - Trip.consumption = data_container.to_consumption() + Trip.consumption = Consumption.create_from_data_container(data_container) # generate schedule from csv schedule = Schedule.from_datacontainer(data_container, args) diff --git a/simba/trip.py b/simba/trip.py index df94804d..23b7565a 100644 --- a/simba/trip.py +++ b/simba/trip.py @@ -10,20 +10,17 @@ def __init__(self, rotation, departure_time, departure_name, arrival_time, arrival_name, distance, temperature, level_of_loading, height_difference, **kwargs): self.departure_name = departure_name - if type(departure_time) is str: - departure_time = datetime.fromisoformat(departure_time) self.departure_time = departure_time - if type(arrival_time) is str: - arrival_time = datetime.fromisoformat(arrival_time) self.arrival_time = arrival_time self.arrival_name = arrival_name - self.distance = float(distance) + self.distance = distance self.line = kwargs.get('line', None) - self.temperature = float(temperature) + self.temperature = temperature self.height_difference = height_difference self.level_of_loading = level_of_loading # mean speed in km/h from distance and travel time or from initialization # travel time is at least 1 min + mean_speed = kwargs.get("mean_speed", (self.distance / 1000) / max(1 / 60, ((self.arrival_time - self.departure_time) / timedelta( hours=1)))) diff --git a/simba/util.py b/simba/util.py index 55e4c1f1..e0516119 100644 --- a/simba/util.py +++ b/simba/util.py @@ -265,7 +265,7 @@ def get_args(): # rename special options args.timing = args.eta - missing = [a for a in ["input_schedule", "electrified_stations"] if vars(args).get(a) is None] + missing = [a for a in ["input_schedule", "electrified_stations_path"] if vars(args).get(a) is None] if missing: raise Exception("The following arguments are required: {}".format(", ".join(missing))) @@ -289,7 +289,7 @@ def get_parser(): help='Path to CSV file containing all trips of schedule to be analyzed.') parser.add_argument('--output-directory', default="data/sim_outputs", help='Location where all simulation outputs are stored') - parser.add_argument('--electrified-stations', help='include electrified_stations json') + parser.add_argument('--electrified-stations-path', help='include electrified_stations json') parser.add_argument('--vehicle-types-path', default="data/examples/vehicle_types.json", help='location of vehicle type definitions') parser.add_argument('--station_data_path', default=None, @@ -301,7 +301,7 @@ def get_parser(): parser.add_argument('--level_of_loading_over_day_path', default=None, help="Use csv. data with 'hour' and level_of_loading' columns to set \ level of loading in case they are not in trips.csv") - parser.add_argument('--cost-parameters-file', default=None, + parser.add_argument('--cost-parameters-path', default=None, help='include cost parameters json, needed if cost_calculation==True') parser.add_argument('--rotation-filter', default=None, help='Use json data with rotation ids') diff --git a/tests/test_schedule.py b/tests/test_schedule.py index c1093fe8..b23bd3e3 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -6,10 +6,11 @@ import spice_ev.scenario as scenario from spice_ev.util import set_options_from_config -from simba.simulate import pre_simulation, create_and_fill_data_container +from simba.simulate import pre_simulation from tests.conftest import example_root, file_root from tests.helpers import generate_basic_schedule, initialize_consumption from simba import rotation, schedule, util +from simba.data_container import DataContainer mandatory_args = { "min_recharge_deps_oppb": 1, @@ -61,7 +62,7 @@ def basic_run(self): args.ALLOW_NEGATIVE_SOC = True args.attach_vehicle_soc = True - data_container = create_and_fill_data_container(args) + data_container = DataContainer().fill_with_args(args) sched, args = pre_simulation(args, data_container) scen = sched.run(args) return sched, scen, args diff --git a/tests/test_simulate.py b/tests/test_simulate.py index 36dcd918..7a961ec5 100644 --- a/tests/test_simulate.py +++ b/tests/test_simulate.py @@ -4,6 +4,7 @@ import pytest import warnings +from simba import util from simba.simulate import simulate @@ -13,10 +14,22 @@ class TestSimulate: # Add propagate_mode_errors as developer setting to raise Exceptions. + NON_DEFAULT_VALUES = { + "vehicle_types_path": example_path / "vehicle_types.json", + "electrified_stations_path": example_path / "electrified_stations.json", + "cost_parameters_path": example_path / "cost_params.json", + "outside_temperature_over_day_path": example_path / "default_temp_summer.csv", + "level_of_loading_over_day_path": example_path / "default_level_of_loading_over_day.csv", + "input_schedule": example_path / "trips_example.csv", + "mode": [], + "interval": 15, + "propagate_mode_errors": True, + } + DEFAULT_VALUES = { "vehicle_types_path": example_path / "vehicle_types.json", - "electrified_stations": example_path / "electrified_stations.json", - "cost_parameters_file": example_path / "cost_params.json", + "electrified_stations_path": example_path / "electrified_stations.json", + "cost_parameters_path": example_path / "cost_params.json", "outside_temperature_over_day_path": example_path / "default_temp_summer.csv", "level_of_loading_over_day_path": example_path / "default_level_of_loading_over_day.csv", "input_schedule": example_path / "trips_example.csv", @@ -44,7 +57,12 @@ class TestSimulate: } def test_basic(self): - args = Namespace(**(self.DEFAULT_VALUES)) + # Get the parser from util + parser = util.get_parser() + # Set the parser defaults to the specified non default values + parser.set_defaults(**self.NON_DEFAULT_VALUES) + # get all args with default values + args, _ = parser.parse_known_args() simulate(args) def test_missing(self): @@ -62,7 +80,7 @@ def test_missing(self): self.DEFAULT_VALUES["propagate_mode_errors"] = values["propagate_mode_errors"] # required file missing - for file_type in ["vehicle_types_path", "electrified_stations", "cost_parameters_file"]: + for file_type in ["vehicle_types_path", "electrified_stations_path", "cost_parameters_file"]: values[file_type] = "" with pytest.raises(Exception): simulate(Namespace(**values)) @@ -71,7 +89,7 @@ def test_missing(self): def test_unknown_mode(self, caplog): # try to run a mode that does not exist - args = Namespace(**(self.DEFAULT_VALUES)) + args, _ = util.get_parser().parse_known_args(self.NON_DEFAULT_VALUES) args.mode = "foo" with caplog.at_level(logging.ERROR): simulate(args) @@ -79,7 +97,7 @@ def test_unknown_mode(self, caplog): def test_late_sim(self, caplog): # sim mode has no function, just produces a log info later - args = Namespace(**(self.DEFAULT_VALUES)) + args, _ = util.get_parser().parse_known_args(self.NON_DEFAULT_VALUES) args.mode = ["sim", "sim"] with caplog.at_level(logging.INFO): simulate(args) diff --git a/tests/test_soc_dispatcher.py b/tests/test_soc_dispatcher.py index ca9a5e11..939eb810 100644 --- a/tests/test_soc_dispatcher.py +++ b/tests/test_soc_dispatcher.py @@ -4,10 +4,11 @@ import pandas as pd import pytest -from simba.simulate import pre_simulation, create_and_fill_data_container +from simba.simulate import pre_simulation from simba.trip import Trip from tests.conftest import example_root from simba import util +from simba.data_container import DataContainer class TestSocDispatcher: @@ -21,7 +22,7 @@ def basic_run(self): args = util.get_args() args.seed = 5 args.attach_vehicle_soc = True - data_container = create_and_fill_data_container(args) + data_container = DataContainer().fill_with_args(args) sched, args = pre_simulation(args, data_container=data_container) # Copy an opportunity rotation twice, so dispatching can be tested From c4463cfb5c7596d25b8deffa5aec935a1fde5eb4 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Wed, 29 May 2024 18:07:12 +0200 Subject: [PATCH 14/68] Make defaul preferred_charging_type consistent with default value from parser --- simba/schedule.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/simba/schedule.py b/simba/schedule.py index 9050f6da..8cd2b35e 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -127,7 +127,7 @@ def from_datacontainer(cls, data_container: DataContainer, args): # charging type for rot in schedule.rotations.values(): if rot.charging_type is None: - rot.set_charging_type(ct=vars(args).get('preferred_charging_type', 'oppb')) + rot.set_charging_type(ct=vars(args).get('preferred_charging_type', 'depb')) if vars(args).get("check_rotation_consistency"): # check rotation expectations @@ -274,7 +274,7 @@ def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): # charging type for rot in schedule.rotations.values(): if rot.charging_type is None: - rot.set_charging_type(ct=kwargs.get('preferred_charging_type', 'oppb')) + rot.set_charging_type(ct=kwargs.get('preferred_charging_type', 'depb')) if kwargs.get("check_rotation_consistency"): # check rotation expectations From 6d02107ccb989d96eb90e26275dce45f5c9fbc78 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 30 May 2024 10:30:45 +0200 Subject: [PATCH 15/68] Add reference to data_container to schedule --- simba/schedule.py | 2 ++ simba/simulate.py | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/simba/schedule.py b/simba/schedule.py index 8cd2b35e..99972596 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -66,6 +66,7 @@ def __init__(self, vehicle_types, stations, **kwargs): self.original_rotations = None self.station_data = None self.soc_dispatcher: SocDispatcher = None + self.data_container: DataContainer = None # mandatory config parameters mandatory_options = [ "min_recharge_deps_oppb", @@ -87,6 +88,7 @@ def __init__(self, vehicle_types, stations, **kwargs): def from_datacontainer(cls, data_container: DataContainer, args): schedule = cls(data_container.vehicle_types_data, data_container.stations_data, **vars(args)) + schedule.data_container = data_container schedule.station_data = data_container.station_geo_data for trip in data_container.trip_data: diff --git a/simba/simulate.py b/simba/simulate.py index 28123103..22159da8 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -141,10 +141,12 @@ class Mode: A function must return the updated schedule and scenario objects. """ + @staticmethod def sim(schedule, scenario, args, _i):# Noqa scenario = schedule.run(args) return schedule, scenario + @staticmethod def service_optimization(schedule, scenario, args, _i): # find largest set of rotations that produce no negative SoC result = optimization.service_optimization(schedule, scenario, args) @@ -154,12 +156,15 @@ def service_optimization(schedule, scenario, args, _i): schedule, scenario = result['original'] return schedule, scenario + @staticmethod def neg_depb_to_oppb(schedule, scenario, args, _i): return Mode.switch_type(schedule, scenario, args, "depb", "oppb") + @staticmethod def neg_oppb_to_depb(schedule, scenario, args, _i): return Mode.switch_type(schedule, scenario, args, "oppb", "depb") + @staticmethod def switch_type(schedule, scenario, args, from_type, to_type): # simple optimization: change charging type, simulate again # get negative rotations @@ -186,6 +191,7 @@ def switch_type(schedule, scenario, args, from_type, to_type): logging.info(f'Rotations {", ".join(neg_rot)} remain negative.') return schedule, scenario + @staticmethod def station_optimization(schedule, scenario, args, i): if not args.optimizer_config: logging.warning("Station optimization needs an optimization config file. " @@ -204,6 +210,7 @@ def station_optimization(schedule, scenario, args, i): 'Optimization was skipped'.format(err)) return original_schedule, original_scenario + @staticmethod def remove_negative(schedule, scenario, args, _i): neg_rot = schedule.get_negative_rotations(scenario) if neg_rot: @@ -216,6 +223,7 @@ def remove_negative(schedule, scenario, args, _i): logging.info('No negative rotations to remove') return schedule, scenario + @staticmethod def split_negative_depb(schedule, scenario, args, _i): negative_rotations = schedule.get_negative_rotations(scenario) trips, depot_trips = optimization.prepare_trips(schedule, negative_rotations) @@ -224,6 +232,7 @@ def split_negative_depb(schedule, scenario, args, _i): scenario = recombined_schedule.run(args) return recombined_schedule, scenario + @staticmethod def report(schedule, scenario, args, i): if args.output_directory is None: return schedule, scenario @@ -232,9 +241,10 @@ def report(schedule, scenario, args, i): if args.cost_calculation: # cost calculation part of report try: - calculate_costs(args.cost_parameters, scenario, schedule, args) + cost_parameters = schedule.data_container.cost_parameters_data + calculate_costs(cost_parameters, scenario, schedule, args) except Exception: - logging.warning(f"Cost calculation failed due to {traceback.print_exc()}") + logging.warning(f"Cost calculation failed due to {traceback.format_exc()}") if args.propagate_mode_errors: raise # name: always start with sim, append all prior optimization modes From c953d3d9a58562c6e0d422ea0119628840a9f7c1 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 30 May 2024 10:34:37 +0200 Subject: [PATCH 16/68] Fix simulate tests --- simba/util.py | 2 +- tests/test_simulate.py | 125 +++++++++++++++++++++-------------------- 2 files changed, 65 insertions(+), 62 deletions(-) diff --git a/simba/util.py b/simba/util.py index e0516119..358c66b3 100644 --- a/simba/util.py +++ b/simba/util.py @@ -292,7 +292,7 @@ def get_parser(): parser.add_argument('--electrified-stations-path', help='include electrified_stations json') parser.add_argument('--vehicle-types-path', default="data/examples/vehicle_types.json", help='location of vehicle type definitions') - parser.add_argument('--station_data_path', default=None, + parser.add_argument('--station-data-path', default=None, help='Use station data to back calculation of consumption with height\ information of stations') parser.add_argument('--outside_temperature_over_day_path', default=None, diff --git a/tests/test_simulate.py b/tests/test_simulate.py index 7a961ec5..82390a3a 100644 --- a/tests/test_simulate.py +++ b/tests/test_simulate.py @@ -17,6 +17,7 @@ class TestSimulate: NON_DEFAULT_VALUES = { "vehicle_types_path": example_path / "vehicle_types.json", "electrified_stations_path": example_path / "electrified_stations.json", + "station_data_path": example_path / "all_stations.csv", "cost_parameters_path": example_path / "cost_params.json", "outside_temperature_over_day_path": example_path / "default_temp_summer.csv", "level_of_loading_over_day_path": example_path / "default_level_of_loading_over_day.csv", @@ -24,9 +25,10 @@ class TestSimulate: "mode": [], "interval": 15, "propagate_mode_errors": True, + "preferred_charging_type": "oppb" } - DEFAULT_VALUES = { + MANDATORY_ARGS = { "vehicle_types_path": example_path / "vehicle_types.json", "electrified_stations_path": example_path / "electrified_stations.json", "cost_parameters_path": example_path / "cost_params.json", @@ -53,43 +55,45 @@ class TestSimulate: "desired_soc_deps": 1, "min_charging_time": 0, "default_voltage_level": "MV", - "propagate_mode_errors": True, } - def test_basic(self): - # Get the parser from util + def get_args(self): + # try to run a mode that does not exist + # Get the parser from util. This way the test is directly coupled to the parser arguments parser = util.get_parser() # Set the parser defaults to the specified non default values parser.set_defaults(**self.NON_DEFAULT_VALUES) # get all args with default values args, _ = parser.parse_known_args() + return args + + def test_basic(self): + # Get the parser from util. This way the test is directly coupled to the parser arguments + args = self.get_args() simulate(args) - def test_missing(self): - # every value in DEFAULT_VALUES is expected to be set, so omitting one should raise an error - values = self.DEFAULT_VALUES.copy() - # except propagate_modes_error, since this is only checked when error needs propagation - del self.DEFAULT_VALUES["propagate_mode_errors"] - for k, v in self.DEFAULT_VALUES.items(): + def test_mandatory_missing(self): + values = self.MANDATORY_ARGS.copy() + + for k, v in self.MANDATORY_ARGS.items(): del values[k] with pytest.raises(Exception): simulate(Namespace(**values)) # reset values[k] = v - # restore the setting for further testing - self.DEFAULT_VALUES["propagate_mode_errors"] = values["propagate_mode_errors"] # required file missing - for file_type in ["vehicle_types_path", "electrified_stations_path", "cost_parameters_file"]: - values[file_type] = "" + for fpath in ["vehicle_types_path", "electrified_stations_path", "cost_parameters_path"]: + values[fpath] = "" with pytest.raises(Exception): simulate(Namespace(**values)) # reset - values[file_type] = self.DEFAULT_VALUES[file_type] + values[fpath] = self.MANDATORY_ARGS[fpath] def test_unknown_mode(self, caplog): # try to run a mode that does not exist - args, _ = util.get_parser().parse_known_args(self.NON_DEFAULT_VALUES) + # Get the parser from util. This way the test is directly coupled to the parser arguments + args = self.get_args() args.mode = "foo" with caplog.at_level(logging.ERROR): simulate(args) @@ -97,7 +101,8 @@ def test_unknown_mode(self, caplog): def test_late_sim(self, caplog): # sim mode has no function, just produces a log info later - args, _ = util.get_parser().parse_known_args(self.NON_DEFAULT_VALUES) + # Get the parser from util. This way the test is directly coupled to the parser arguments + args = self.get_args() args.mode = ["sim", "sim"] with caplog.at_level(logging.INFO): simulate(args) @@ -106,66 +111,64 @@ def test_late_sim(self, caplog): def test_mode_service_opt(self): # basic run - values = self.DEFAULT_VALUES.copy() - values["mode"] = "service_optimization" - simulate(Namespace(**values)) + # Get the parser from util. This way the test is directly coupled to the parser arguments + args = self.get_args() + args.mode = "service_optimization" + simulate(args) # all rotations remain negative - values["desired_soc_deps"] = 0 - values["desired_soc_opps"] = 0 - values["ALLOW_NEGATIVE_SOC"] = True - simulate(Namespace(**values)) + args.desired_soc_deps = 0 + args.desired_soc_opps = 0 + args.ALLOW_NEGATIVE_SOC = True + simulate(args) def test_mode_change_charge_type(self): # all rotations remain negative - values = self.DEFAULT_VALUES.copy() - values["mode"] = "neg_oppb_to_depb" - values["desired_soc_deps"] = 0 - values["desired_soc_opps"] = 0 - values["ALLOW_NEGATIVE_SOC"] = True - simulate(Namespace(**values)) + args = self.get_args() + args.mode = "neg_oppb_to_depb" + args.desired_soc_deps = 0 + args.desired_soc_opps = 0 + args.ALLOW_NEGATIVE_SOC = True + simulate(args) def test_mode_remove_negative(self): - values = self.DEFAULT_VALUES.copy() - values["mode"] = "remove_negative" - values["desired_soc_deps"] = 0 - # values["desired_soc_opps"] = 0 - values["ALLOW_NEGATIVE_SOC"] = True - simulate(Namespace(**values)) + args = self.get_args() + args.mode = "remove_negative" + args.desired_soc_deps= 0 + args.ALLOW_NEGATIVE_SOC= True + simulate(args) def test_mode_report(self, tmp_path): # report with cost calculation, write to tmp - values = self.DEFAULT_VALUES.copy() - values["mode"] = "report" - values["cost_calculation"] = True - values["output_directory"] = tmp_path - values["strategy"] = "distributed" - values["strategy_deps"] = "balanced" - values["strategy_opps"] = "greedy" - - values["show_plots"] = False + args = self.get_args() + args.mode = "report" + args.cost_calculation = True + args.output_directory = tmp_path + args.strategy = "distributed" + args.strategy_deps = "balanced" + args.strategy_opps = "greedy" + + args.show_plots = False # tuned so that some rotations don't complete - values["days"] = .33 + args.days = .33 with warnings.catch_warnings(): warnings.simplefilter("ignore") - simulate(Namespace(**values)) + simulate(args) def test_empty_report(self, tmp_path): # report with no rotations - values = self.DEFAULT_VALUES.copy() - values.update({ - "mode": ["remove_negative", "report"], - "desired_soc_deps": 0, - "ALLOW_NEGATIVE_SOC": True, - "cost_calculation": True, - "output_directory": tmp_path, - "strategy": "distributed", - "show_plots": False, - }) + args = self.get_args() + args.mode = ["remove_negative", "report"] + args.desired_soc_deps = 0 + args.ALLOW_NEGATIVE_SOC = True + args.cost_calculation = True + args.output_directory = tmp_path + args.strategy = "distributed" + args.show_plots = False with warnings.catch_warnings(): warnings.simplefilter("ignore") - simulate(Namespace(**values)) + simulate(args) def test_mode_recombination(self): - values = self.DEFAULT_VALUES.copy() - values["mode"] = "recombination" - simulate(Namespace(**values)) + args = self.get_args() + args.mode = "recombination" + simulate(args) From 0130ebd7af0b09d8545e38010f810f71750bcf1d Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 30 May 2024 10:44:25 +0200 Subject: [PATCH 17/68] Make datacontainer from args more explicit --- simba/data_container.py | 90 ++++++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/simba/data_container.py b/simba/data_container.py index b89c38f9..db3ac7bf 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -25,22 +25,54 @@ def __init__(self): self.trip_data: [dict] = [] def fill_with_args(self, args: argparse.Namespace): - return self.fill_with_paths(args.vehicle_types_path, - args.electrified_stations_path, - args.cost_parameters_path, - args.input_schedule, - args.outside_temperature_over_day_path, - args.level_of_loading_over_day_path, - args.station_data_path, - ) + cost_parameters_path = args.cost_parameters_path + if not args.cost_calculation: + cost_parameters_path = None + + return self.fill_with_paths( + trips_file_path=args.input_schedule, + vehicle_types_path=args.vehicle_types_path, + electrified_stations_path=args.electrified_stations_path, + cost_parameters_path=cost_parameters_path, + outside_temperature_over_day_path=args.outside_temperature_over_day_path, + level_of_loading_over_day_path=args.level_of_loading_over_day_path, + station_data_path=args.station_data_path, + ) + + def add_trip_data_from_csv(self, file_path: Path) -> 'DataContainer': + """ Add trip data from csv file to DataContainer""" + + self.trip_data = [] + with open(file_path, 'r', encoding='utf-8') as trips_file: + trip_reader = csv.DictReader(trips_file) + for trip in trip_reader: + trip_d = dict(trip) + trip_d["arrival_time"] = datetime.datetime.fromisoformat(trip["arrival_time"]) + trip_d["departure_time"] = datetime.datetime.fromisoformat(trip["departure_time"]) + trip_d["level_of_loading"] = cast_float_or_none(trip.get("level_of_loading")) + trip_d["temperature"] = cast_float_or_none(trip.get("temperature")) + trip_d["distance"] = float(trip["distance"]) + self.trip_data.append(trip_d) + + def add_station_geo_data(self, data: dict) -> None: + """Add station_geo data to the data container. + + Used when adding station_geo to a data container from any source + :param data: data containing station_geo + :type data: dict + """ + self.station_geo_data = data + + + def fill_with_paths(self, + trips_file_path, vehicle_types_path, electrified_stations_path, - cost_parameters_file, - trips_file_path, - outside_temperature_over_day_path, - level_of_loading_over_day_path, + outside_temperature_over_day_path=None, + level_of_loading_over_day_path=None, + cost_parameters_path=None, station_data_path=None, ): # Add the vehicle_types from a json file @@ -58,43 +90,19 @@ def fill_with_paths(self, # Add electrified_stations data self.add_stations_from_json(electrified_stations_path) + # Add cost_parameters_data + if cost_parameters_path: + self.add_cost_parameters_from_json(cost_parameters_path) + # Add station geo data if station_data_path is not None: self.add_station_geo_data_from_csv(station_data_path) - # Add cost_parameters_data - self.add_cost_parameters_from_json(cost_parameters_file) + self.add_trip_data_from_csv(trips_file_path) return self - def add_trip_data_from_csv(self, file_path: Path) -> 'DataContainer': - """ Add trip data from csv file to DataContainer""" - - self.trip_data = [] - with open(file_path, 'r', encoding='utf-8') as trips_file: - trip_reader = csv.DictReader(trips_file) - for trip in trip_reader: - trip_d = dict(trip) - trip_d["arrival_time"] = datetime.datetime.fromisoformat(trip["arrival_time"]) - trip_d["departure_time"] = datetime.datetime.fromisoformat(trip["departure_time"]) - trip_d["level_of_loading"] = cast_float_or_none(trip.get("level_of_loading")) - trip_d["temperature"] = cast_float_or_none(trip.get("temperature")) - trip_d["distance"] = float(trip["distance"]) - self.trip_data.append(trip_d) - - - - - def add_station_geo_data(self, data: dict) -> None: - """Add station_geo data to the data container. - - Used when adding station_geo to a data container from any source - :param data: data containing station_geo - :type data: dict - """ - self.station_geo_data = data - def add_station_geo_data_from_csv(self, file_path: Path) -> 'DataContainer': # find the temperature and elevation of the stations by reading the .csv file. # this data is stored in the schedule and passed to the trips, which use the information From da2cbe299ab9d1c7ffb919e6e77d6dfa5aa80f18 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 30 May 2024 10:47:56 +0200 Subject: [PATCH 18/68] Fix main --- simba/__main__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/simba/__main__.py b/simba/__main__.py index 9863cd3c..46b5ccec 100644 --- a/simba/__main__.py +++ b/simba/__main__.py @@ -23,13 +23,13 @@ args.output_directory = None if args.output_directory is not None: # copy input files to output to ensure reproducibility - copy_list = [args.config, args.electrified_stations, args.vehicle_types_path] + copy_list = [args.config, args.electrified_stations_path, args.vehicle_types_path] if "station_optimization" in args.mode: copy_list.append(args.optimizer_config) # only copy cost params if they exist - if args.cost_parameters_file is not None: - copy_list.append(args.cost_parameters_file) + if args.cost_parameters_path is not None: + copy_list.append(args.cost_parameters_path) for c_file in map(Path, copy_list): shutil.copy(c_file, args.output_directory_input / c_file.name) From 58bdb5e5cd30632238ed06af60fbcc36cc51e58a Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 30 May 2024 10:49:21 +0200 Subject: [PATCH 19/68] Always load cost_parameters if path given --- simba/data_container.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/simba/data_container.py b/simba/data_container.py index db3ac7bf..144ac34a 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -25,15 +25,11 @@ def __init__(self): self.trip_data: [dict] = [] def fill_with_args(self, args: argparse.Namespace): - cost_parameters_path = args.cost_parameters_path - if not args.cost_calculation: - cost_parameters_path = None - return self.fill_with_paths( trips_file_path=args.input_schedule, vehicle_types_path=args.vehicle_types_path, electrified_stations_path=args.electrified_stations_path, - cost_parameters_path=cost_parameters_path, + cost_parameters_path=args.cost_parameters_path, outside_temperature_over_day_path=args.outside_temperature_over_day_path, level_of_loading_over_day_path=args.level_of_loading_over_day_path, station_data_path=args.station_data_path, From 52b42875601bb21ab76b6adeba769b438c5ece7d Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 30 May 2024 11:27:41 +0200 Subject: [PATCH 20/68] Refactor with datacontainer --- tests/test_schedule.py | 39 +++++++++++++++------------------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/tests/test_schedule.py b/tests/test_schedule.py index b23bd3e3..31192b8e 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -26,11 +26,11 @@ class BasicSchedule: temperature_path = example_root / 'default_temp_winter.csv' lol_path = example_root / 'default_level_of_loading_over_day.csv' - + vehicle_types_path = example_root / "vehicle_types.json" with open(example_root / "electrified_stations.json", "r", encoding='utf-8') as file: electrified_stations = util.uncomment_json_file(file) - with open(example_root / "vehicle_types.json", "r", encoding='utf-8') as file: + with open(vehicle_types_path, "r", encoding='utf-8') as file: vehicle_types = util.uncomment_json_file(file) path_to_all_station_data = example_root / "all_stations.csv" @@ -330,23 +330,19 @@ def test_scenario_with_feed_in(self, default_schedule_arguments): sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] args = util.get_args() args.config = example_root / "simba.cfg" - electrified_stations_path = example_root / "electrified_stations.json" - args.electrified_stations = electrified_stations_path - with open(electrified_stations_path, "r", encoding='utf-8') as file: - electrified_stations = util.uncomment_json_file(file) args.days = None args.seed = 5 - initialize_consumption(self.vehicle_types) + args.input_schedule = example_root / "trips_example.csv" + args.electrified_stations_path = example_root / "electrified_stations.json" + args.station_data_path = example_root / "all_stations.csv" + args.vehicle_type_path = self.vehicle_types_path + args.level_of_loading_over_day_path = self.lol_path + args.outside_temperature_over_day_path = self.temperature_path - default_schedule_arguments["path_to_csv"] = example_root / "trips_example.csv" - default_schedule_arguments["stations"] = electrified_stations - default_schedule_arguments["station_data_path"] = example_root / "all_stations.csv" - default_schedule_arguments["path_to_trips"] = example_root / "trips_example.csv" - generated_schedule = schedule.Schedule.from_csv(**default_schedule_arguments) - # Create soc dispatcher - generated_schedule.init_soc_dispatcher(args) + data_container = DataContainer().fill_with_args(args) + generated_schedule, args = pre_simulation(args, data_container) set_options_from_config(args, verbose=False) args.ALLOW_NEGATIVE_SOC = True @@ -362,17 +358,12 @@ def test_scenario_with_feed_in(self, default_schedule_arguments): scen = generated_schedule.run(args) assert type(scen) is scenario.Scenario - with open(electrified_stations_path, "r", encoding='utf-8') as file: - electrified_stations = util.uncomment_json_file(file) - - electrified_stations["Station-0"]["energy_feed_in"]["csv_file"] = file_root / "notafile" - electrified_stations["Station-0"]["external_load"]["csv_file"] = file_root / "notafile" + # Change a station + station = data_container.stations_data["Station-0"] + station["energy_feed_in"]["csv_file"] = file_root / "notafile" + station["external_load"]["csv_file"] = file_root / "notafile" - default_schedule_arguments["stations"] = electrified_stations - generated_schedule = schedule.Schedule.from_csv(**default_schedule_arguments) - - # Create soc dispatcher - generated_schedule.init_soc_dispatcher(args) + generated_schedule, args = pre_simulation(args, data_container) set_options_from_config(args, verbose=False) From 5fb4b0db22cffe3ed44ebbf67d4ef12c3215b1f1 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 30 May 2024 12:22:50 +0200 Subject: [PATCH 21/68] Reduce amount of expected warnings --- tests/test_schedule.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 31192b8e..6500573d 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -344,7 +344,6 @@ def test_scenario_with_feed_in(self, default_schedule_arguments): data_container = DataContainer().fill_with_args(args) generated_schedule, args = pre_simulation(args, data_container) - set_options_from_config(args, verbose=False) args.ALLOW_NEGATIVE_SOC = True args.attach_vehicle_soc = True scen = generated_schedule.generate_scenario(args) @@ -365,8 +364,6 @@ def test_scenario_with_feed_in(self, default_schedule_arguments): generated_schedule, args = pre_simulation(args, data_container) - set_options_from_config(args, verbose=False) - # check that 2 user warnings are put out for missing files and an error is thrown with pytest.warns(Warning) as record: try: @@ -374,7 +371,7 @@ def test_scenario_with_feed_in(self, default_schedule_arguments): except FileNotFoundError: user_warning_count = sum([1 for warning in record.list if warning.category == UserWarning]) - assert user_warning_count == 3 + assert user_warning_count == 2 else: assert 0, "No error despite wrong file paths" From 668b001cab3db0f4934e18bc5c116b64fcc23f39 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 30 May 2024 14:36:14 +0200 Subject: [PATCH 22/68] Add greedy simulation type --- simba/schedule.py | 11 ++++++----- simba/simulate.py | 46 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/simba/schedule.py b/simba/schedule.py index 99972596..8cecd1c5 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -346,7 +346,7 @@ def check_consistency(cls, schedule): inconsistent_rotations[rot_id] = str(e) return inconsistent_rotations - def run(self, args): + def run(self, args, mode="distributed"): """Runs a schedule without assigning vehicles. For external usage the core run functionality is accessible through this function. It @@ -354,11 +354,14 @@ def run(self, args): :param args: used arguments are rotation_filter, path to rotation ids, and rotation_filter_variable that sets mode (options: include, exclude) :type args: argparse.Namespace + :param mode: option of "distributed" or "greedy" + :type mode: str :return: scenario :rtype spice_ev.Scenario """ # Make sure all rotations have an assigned vehicle assert all([rot.vehicle_id is not None for rot in self.rotations.values()]) + assert mode in ["distributed", "greedy"] scenario = self.generate_scenario(args) logging.info("Running SpiceEV...") @@ -367,10 +370,10 @@ def run(self, args): # logging.root.level is lowest of console and file (if present) with warnings.catch_warnings(): warnings.simplefilter('ignore', UserWarning) - scenario.run('distributed', vars(args).copy()) + scenario.run(mode, vars(args).copy()) else: # debug: log SpiceEV warnings as well - scenario.run('distributed', vars(args).copy()) + scenario.run(mode, vars(args).copy()) assert scenario.step_i == scenario.n_intervals, \ 'SpiceEV simulation aborted, see above for details' return scenario @@ -705,8 +708,6 @@ def get_charge_curves(self, charge_levels, final_value: float, time_step_min: fl def calculate_consumption(self): """ Computes consumption for all trips of all rotations. - Depends on vehicle type only, not on charging type. - :return: Total consumption for entire schedule [kWh] :rtype: float """ diff --git a/simba/simulate.py b/simba/simulate.py index 22159da8..f3f3855d 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -2,7 +2,7 @@ import traceback from copy import deepcopy -from simba import report, optimization, util, consumption +from simba import report, optimization, util, consumption, optimizer_util from simba.consumption import Consumption from simba.data_container import DataContainer from simba.costs import calculate_costs @@ -125,8 +125,9 @@ def modes_simulation(schedule, scenario, args): args.mode = args.mode[:i] + ["ABORTED"] if args.output_directory is None: create_results_directory(args, i+1) - report.generate_plots(scenario, args) - logging.info(f"Created plot of failed scenario in {args.results_directory}") + if not args.skip_plots: + report.generate_plots(scenario, args) + logging.info(f"Created plot of failed scenario in {args.results_directory}") # continue with other modes after error # all modes done @@ -140,10 +141,14 @@ class Mode: Optionally, an index of the current mode in the modelist can be given. A function must return the updated schedule and scenario objects. """ + @staticmethod + def sim_greedy(schedule, scenario, args, _i):# Noqa + scenario = schedule.run(args, mode="greedy") + return schedule, scenario @staticmethod def sim(schedule, scenario, args, _i):# Noqa - scenario = schedule.run(args) + scenario = schedule.run(args, mode="distributed") return schedule, scenario @staticmethod @@ -193,11 +198,13 @@ def switch_type(schedule, scenario, args, from_type, to_type): @staticmethod def station_optimization(schedule, scenario, args, i): - if not args.optimizer_config: + conf = optimizer_util.OptimizerConfig() + if args.optimizer_config: + conf = read_optimizer_config(args.optimizer_config) + else: logging.warning("Station optimization needs an optimization config file. " - "Since no path was given, station optimization is skipped") - return schedule, scenario - conf = read_optimizer_config(args.optimizer_config) + "Default Config is used.") + # Get copies of the original schedule and scenario. In case of an exception the outer # schedule and scenario stay intact. original_schedule = deepcopy(schedule) @@ -210,6 +217,29 @@ def station_optimization(schedule, scenario, args, i): 'Optimization was skipped'.format(err)) return original_schedule, original_scenario + @staticmethod + def station_optimization_single_step(schedule, scenario, args, i): + """ Electrify only the station with the highest potential""" # noqa + if not args.optimizer_config: + logging.warning("Station optimization needs an optimization config file. " + "Default Config is used.") + conf = optimizer_util.OptimizerConfig().set_defaults() + else: + conf = read_optimizer_config(args.optimizer_config) + + conf.early_return = True + # Work on copies of the original schedule and scenario. In case of an exception the outer + # schedule and scenario stay intact. + original_schedule = deepcopy(schedule) + original_scenario = deepcopy(scenario) + try: + create_results_directory(args, i+1) + return run_optimization(conf, sched=schedule, scen=scenario, args=args) + except Exception as err: + logging.warning('During Station optimization an error occurred {0}. ' + 'Optimization was skipped'.format(err)) + return original_schedule, original_scenario + @staticmethod def remove_negative(schedule, scenario, args, _i): neg_rot = schedule.get_negative_rotations(scenario) From 554c3cc46a0a3cb49746cb93003112981e608924 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 30 May 2024 14:41:52 +0200 Subject: [PATCH 23/68] Make flake8 happy --- simba/consumption.py | 3 +- simba/data_container.py | 111 ++++++++++++++++++----------- simba/schedule.py | 19 +++-- simba/simulate.py | 19 +++-- simba/trip.py | 2 +- simba/util.py | 3 +- tests/test_simulate.py | 4 +- tests/test_station_optimization.py | 1 - 8 files changed, 101 insertions(+), 61 deletions(-) diff --git a/simba/consumption.py b/simba/consumption.py index 63a589a9..7e27467a 100644 --- a/simba/consumption.py +++ b/simba/consumption.py @@ -107,6 +107,8 @@ def calculate_consumption(self, time, distance, vehicle_type, charging_type, tem def create_from_data_container(data_container: DataContainer): """Build a consumption instance from the stored data in the data container. :returns: Consumption instance + :param data_container: container with consumption and vehicle type data + :type data_container: DataContainer """ # setup consumption calculator that can be accessed by all trips consumption = Consumption(data_container.vehicle_types_data) @@ -114,7 +116,6 @@ def create_from_data_container(data_container: DataContainer): consumption.set_consumption_interpolation(name, df) return consumption - def set_consumption_interpolation(self, consumption_lookup_name: str, df: pd.DataFrame): """ Set interpolation function for consumption lookup. diff --git a/simba/data_container.py b/simba/data_container.py index 144ac34a..f5e83ec4 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -24,7 +24,15 @@ def __init__(self): self.trip_data: [dict] = [] - def fill_with_args(self, args: argparse.Namespace): + def fill_with_args(self, args: argparse.Namespace) -> 'DataContainer': + """ Fill self with data from file_paths defined in args + + :param args: Arguments containing paths for input_schedule, vehicle_types_path, + electrified_stations_path, cost_parameters_path, outside_temperature_over_day_path, + level_of_loading_over_day_path, station_data_path + :return: self + """ + return self.fill_with_paths( trips_file_path=args.input_schedule, vehicle_types_path=args.vehicle_types_path, @@ -36,7 +44,11 @@ def fill_with_args(self, args: argparse.Namespace): ) def add_trip_data_from_csv(self, file_path: Path) -> 'DataContainer': - """ Add trip data from csv file to DataContainer""" + """ Add trip data from csv file to DataContainer + + :param file_path: csv file path + :return: self with trip data + """ self.trip_data = [] with open(file_path, 'r', encoding='utf-8') as trips_file: @@ -49,6 +61,7 @@ def add_trip_data_from_csv(self, file_path: Path) -> 'DataContainer': trip_d["temperature"] = cast_float_or_none(trip.get("temperature")) trip_d["distance"] = float(trip["distance"]) self.trip_data.append(trip_d) + return self def add_station_geo_data(self, data: dict) -> None: """Add station_geo data to the data container. @@ -59,9 +72,6 @@ def add_station_geo_data(self, data: dict) -> None: """ self.station_geo_data = data - - - def fill_with_paths(self, trips_file_path, vehicle_types_path, @@ -94,8 +104,6 @@ def fill_with_paths(self, if station_data_path is not None: self.add_station_geo_data_from_csv(station_data_path) - - self.add_trip_data_from_csv(trips_file_path) return self @@ -109,32 +117,34 @@ def add_station_geo_data_from_csv(self, file_path: Path) -> 'DataContainer': delim = util.get_csv_delim(file_path) reader = csv.DictReader(f, delimiter=delim) for row in reader: - self.station_geo_data[str(row['Endhaltestelle'])]= { + self.station_geo_data[str(row['Endhaltestelle'])] = { "elevation": float(row['elevation']), "lat": float(row.get('lat', 0)), "long": float(row.get('long', 0)), } except FileNotFoundError or KeyError: logging.warning("Warning: external csv file '{}' not found or not named properly " - "(Needed column names are 'Endhaltestelle' and 'elevation')". - format(file_path), - stacklevel=100) + "(Needed column names are 'Endhaltestelle' and 'elevation')". + format(file_path), + stacklevel=100) except ValueError: logging.warning("Warning: external csv file '{}' does not contain numeric " - "values in the column 'elevation'. Station data is discarded.". - format(file_path), - stacklevel=100) + "values in the column 'elevation'. Station data is discarded.". + format(file_path), + stacklevel=100) return self - def add_level_of_loading_data(self, data: dict) -> None: + def add_level_of_loading_data(self, data: dict) -> 'DataContainer': """Add level_of_loading data to the data container. Used when adding level_of_loading to a data container from any source - :param data: data containing level_of_loading + :param data: data containing hour and level_of_loading :type data: dict + :return: DataContainer containing level of loading data """ self.level_of_loading_data = data + return self def add_level_of_loading_data_from_csv(self, file_path: Path) -> 'DataContainer': index = "hour" @@ -143,14 +153,16 @@ def add_level_of_loading_data_from_csv(self, file_path: Path) -> 'DataContainer' self.add_level_of_loading_data(level_of_loading_data_dict) return self - def add_temperature_data(self, data: dict) -> None: + def add_temperature_data(self, data: dict) -> 'DataContainer': """Add temperature data to the data container. Used when adding temperature to a data container from any source :param data: data containing temperature :type data: dict + :return: DataContainer containing temperature data """ self.temperature_data = data + return self def add_temperature_data_from_csv(self, file_path: Path) -> 'DataContainer': index = "hour" @@ -159,45 +171,47 @@ def add_temperature_data_from_csv(self, file_path: Path) -> 'DataContainer': self.add_temperature_data(temperature_data_dict) return self - def add_cost_parameters(self, data: dict) -> None: + def add_cost_parameters(self, data: dict) -> 'DataContainer': """Add cost_parameters data to the data container. cost_parameters will be stored in the args instance Used when adding cost_parameters to a data container from any source :param data: data containing cost_parameters :type data: dict + :return: DataContainer containing station data / electrified stations """ self.cost_parameters_data = data + return self def add_cost_parameters_from_json(self, file_path: Path) -> 'DataContainer': - """ Get json data from a file_path""" - try: - with open(file_path, encoding='utf-8') as f: - cost_parameters = util.uncomment_json_file(f) - except FileNotFoundError: - raise Exception(f"Path to cost parameters ({file_path}) " - "does not exist. Exiting...") + """ Get cost parameters data from a path and raise verbose error if file is not found. + + :param file_path: file path + :return: DataContainer containing cost parameters + """ + cost_parameters = self.get_json_from_file(file_path, "cost parameters") self.add_cost_parameters(cost_parameters) return self - def add_stations(self, data: dict) -> None: + def add_stations(self, data: dict) -> 'DataContainer': """Add station data to the data container. Stations will be stored in the Schedule instance Used when adding stations to a data container from any source :param data: data containing stations :type data: dict + :return: DataContainer containing station data / electrified stations """ self.stations_data = data + return self def add_stations_from_json(self, file_path: Path) -> 'DataContainer': - """ Get json data from a file_path""" - try: - with open(file_path, encoding='utf-8') as f: - stations = util.uncomment_json_file(f) - except FileNotFoundError: - raise Exception(f"Path to electrified stations ({file_path}) " - "does not exist. Exiting...") + """ Get electrified_stations data from a file_path + :param file_path: file path + :return: DataContainer containing station data / electrified stations + + """ + stations = self.get_json_from_file(file_path, "electrified stations") self.add_stations(stations) return self @@ -208,19 +222,34 @@ def add_vehicle_types(self, data: dict) -> None: Used when adding new vehicle types to a data container from any source :param data: data containing vehicle_types :type data: dict + :return: DataContainer containing vehicle types """ self.vehicle_types_data = data + return self def add_vehicle_types_from_json(self, file_path: Path): - """ Get json data from a file_path""" + """ Get vehicle_types from a json file_path + :param file_path: file path + :return: DataContainer containing vehicle types + """ + vehicle_types = self.get_json_from_file(file_path, "vehicle types") + self.add_vehicle_types(vehicle_types) + return self + + @staticmethod + def get_json_from_file(file_path: Path, data_type: str) -> any: + """ Get json data from a file_path and raise verbose error if it fails. + :raises FileNotFoundError: if file does not exist + :param file_path: file path + :param data_type: data type used for error description + :return: json data + """ try: with open(file_path, encoding='utf-8') as f: - vehicle_types = util.uncomment_json_file(f) + return util.uncomment_json_file(f) except FileNotFoundError: - raise Exception(f"Path to vehicle types ({file_path}) " - "does not exist. Exiting...") - self.add_vehicle_types(vehicle_types) - return self + raise FileNotFoundError(f"Path to {data_type} ({file_path}) " + "does not exist. Exiting...") def add_consumption_data_from_vehicle_type_linked_files(self): assert self.vehicle_types_data, "No vehicle_type data in the data_container" @@ -254,6 +283,7 @@ def add_consumption_data(self, data_name, df: pd.DataFrame) -> 'DataContainer': return self + def get_values_from_nested_key(key, data: dict) -> list: """Get all the values of the specified key in a nested dict @@ -280,8 +310,9 @@ def get_dict_from_csv(column, file_path, index): output[float(row[index])] = float(row[column]) return output + def cast_float_or_none(val: any) -> any: try: return float(val) except (ValueError, TypeError): - return None \ No newline at end of file + return None diff --git a/simba/schedule.py b/simba/schedule.py index 8cecd1c5..21b8671a 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -85,13 +85,13 @@ def __init__(self, vehicle_types, stations, **kwargs): setattr(self, opt, kwargs.get(opt)) @classmethod - def from_datacontainer(cls, data_container: DataContainer, args): + def from_datacontainer(cls, data: DataContainer, args): - schedule = cls(data_container.vehicle_types_data, data_container.stations_data, **vars(args)) - schedule.data_container = data_container - schedule.station_data = data_container.station_geo_data + schedule = cls(data.vehicle_types_data, data.stations_data, **vars(args)) + schedule.data_container = data + schedule.station_data = data.station_geo_data - for trip in data_container.trip_data: + for trip in data.trip_data: rotation_id = trip['rotation_id'] # trip gets reference to station data and calculates height diff during trip # initialization. Could also get the height difference from here on @@ -108,14 +108,14 @@ def from_datacontainer(cls, data_container: DataContainer, args): trip["departure_name"], trip["arrival_name"]) if trip["level_of_loading"] is None: - trip["level_of_loading"] = data_container.level_of_loading_data.get(hour) + trip["level_of_loading"] = data.level_of_loading_data.get(hour) else: if not 0 <= trip["level_of_loading"] <= 1: logging.warning("Level of loading is out of range [0,1] and will be clipped.") trip["level_of_loading"] = min(1, max(0, trip["level_of_loading"])) if trip["temperature"] is None: - trip["temperature"] = data_container.temperature_data.get(hour) + trip["temperature"] = data.temperature_data.get(hour) if rotation_id not in schedule.rotations.keys(): schedule.rotations.update({ @@ -146,12 +146,10 @@ def from_datacontainer(cls, data_container: DataContainer, args): del schedule.rotations[rot_id] elif vars(args).get("skip_inconsistent_rotations"): logging.warning("Option skip_inconsistent_rotations ignored, " - "as check_rotation_consistency is not set to 'true'") + "as check_rotation_consistency is not set to 'true'") return schedule - - @classmethod def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): """ Constructs Schedule object from CSV file containing all trips of schedule. @@ -1198,6 +1196,7 @@ def get_dict_from_csv(cls, column, file_path, index): output[float(row[index])] = float(row[column]) return output + def update_csv_file_info(file_info, gc_name): """ add infos to csv information dictionary from electrified station diff --git a/simba/simulate.py b/simba/simulate.py index f3f3855d..c21b6cd0 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -2,7 +2,7 @@ import traceback from copy import deepcopy -from simba import report, optimization, util, consumption, optimizer_util +from simba import report, optimization, optimizer_util from simba.consumption import Consumption from simba.data_container import DataContainer from simba.costs import calculate_costs @@ -24,7 +24,7 @@ def simulate(args): :return: final schedule and scenario :rtype: tuple """ - # The data_container stores various input data. + # The data stores various input data. data_container = DataContainer().fill_with_args(args) schedule, args = pre_simulation(args, data_container) @@ -33,7 +33,6 @@ def simulate(args): return schedule, scenario - def pre_simulation(args, data_container: DataContainer): """ Prepare simulation. @@ -44,7 +43,6 @@ def pre_simulation(args, data_container: DataContainer): :type args: Namespace :param data_container: data needed for simulation :type data_container: DataContainer - :raises Exception: If an input file does not exist, exit the program. :return: schedule, args :rtype: simba.schedule.Schedule, Namespace """ @@ -219,7 +217,18 @@ def station_optimization(schedule, scenario, args, i): @staticmethod def station_optimization_single_step(schedule, scenario, args, i): - """ Electrify only the station with the highest potential""" # noqa + """ Electrify only the station with the highest potential + + :param schedule: Schedule + :type schedule: simba.schedule.Schedule + :param scenario: Scenario + :type scenario: spice_ev.scenario.Scenario + :param args: Arguments + :type args: argparse.Namespace + :param i: counter of modes for directory creation + :return: schedule, scenario + + """ # noqa if not args.optimizer_config: logging.warning("Station optimization needs an optimization config file. " "Default Config is used.") diff --git a/simba/trip.py b/simba/trip.py index 23b7565a..7ceea773 100644 --- a/simba/trip.py +++ b/simba/trip.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import timedelta import simba.consumption diff --git a/simba/util.py b/simba/util.py index 358c66b3..3ae4e663 100644 --- a/simba/util.py +++ b/simba/util.py @@ -265,7 +265,8 @@ def get_args(): # rename special options args.timing = args.eta - missing = [a for a in ["input_schedule", "electrified_stations_path"] if vars(args).get(a) is None] + mandatory_arguments = ["input_schedule", "electrified_stations_path"] + missing = [a for a in mandatory_arguments if vars(args).get(a) is None] if missing: raise Exception("The following arguments are required: {}".format(", ".join(missing))) diff --git a/tests/test_simulate.py b/tests/test_simulate.py index 82390a3a..af2b3b94 100644 --- a/tests/test_simulate.py +++ b/tests/test_simulate.py @@ -133,8 +133,8 @@ def test_mode_change_charge_type(self): def test_mode_remove_negative(self): args = self.get_args() args.mode = "remove_negative" - args.desired_soc_deps= 0 - args.ALLOW_NEGATIVE_SOC= True + args.desired_soc_deps = 0 + args.ALLOW_NEGATIVE_SOC = True simulate(args) def test_mode_report(self, tmp_path): diff --git a/tests/test_station_optimization.py b/tests/test_station_optimization.py index e69fb00a..d1ad25c9 100644 --- a/tests/test_station_optimization.py +++ b/tests/test_station_optimization.py @@ -9,7 +9,6 @@ import shutil from simba import station_optimizer -from simba.consumption import Consumption import simba.optimizer_util as opt_util from simba.schedule import Schedule from simba.station_optimization import run_optimization From 962d53c9c21ee7e964ac774f090e2209a047687d Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 30 May 2024 15:23:09 +0200 Subject: [PATCH 24/68] Remove schedule.from_csv --- simba/schedule.py | 145 ---------------------------------------------- 1 file changed, 145 deletions(-) diff --git a/simba/schedule.py b/simba/schedule.py index 21b8671a..338260cb 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -150,151 +150,6 @@ def from_datacontainer(cls, data: DataContainer, args): return schedule - @classmethod - def from_csv(cls, path_to_csv, vehicle_types, stations, **kwargs): - """ Constructs Schedule object from CSV file containing all trips of schedule. - - :param path_to_csv: Path to csv file containing trip data - :type path_to_csv: str - :param vehicle_types: Collection of vehicle types and their properties. - :type vehicle_types: dict - :param stations: json of electrified stations - :type stations: string - :param kwargs: Command line arguments - :type kwargs: dict - :raises NotImplementedError: if stations is neither a str,Path nor dictionary. - :return: Returns a new instance of Schedule with all trips from csv loaded. - :rtype: Schedule - """ - - # Check station type - if isinstance(stations, (str, Path)): - with open(Path(stations), "r") as f: - stations_dict = util.uncomment_json_file(f) - elif isinstance(stations, dict): - stations_dict = stations - else: - raise NotImplementedError - - schedule = cls(vehicle_types, stations_dict, **kwargs) - - station_data = dict() - station_path = kwargs.get("station_data_path") - - level_of_loading_path = kwargs.get("level_of_loading_over_day_path", None) - - if level_of_loading_path is not None: - index = "hour" - column = "level_of_loading" - level_of_loading_dict = cls.get_dict_from_csv(column, level_of_loading_path, index) - - temperature_path = kwargs.get("outside_temperature_over_day_path", None) - if temperature_path is not None: - index = "hour" - column = "temperature" - temperature_data_dict = cls.get_dict_from_csv(column, temperature_path, index) - - # find the temperature and elevation of the stations by reading the .csv file. - # this data is stored in the schedule and passed to the trips, which use the information - # for consumption calculation. Missing station data is handled with default values. - if station_path is not None: - try: - with open(station_path, "r", encoding='utf-8') as f: - delim = util.get_csv_delim(station_path) - reader = csv.DictReader(f, delimiter=delim) - for row in reader: - station_data.update({str(row['Endhaltestelle']): { - "elevation": float(row['elevation']), "lat": float(row.get('lat', 0)), - "long": float(row.get('long', 0))} - }) - except FileNotFoundError or KeyError: - warnings.warn("Warning: external csv file '{}' not found or not named properly " - "(Needed column names are 'Endhaltestelle' and 'elevation')". - format(station_path), - stacklevel=100) - except ValueError: - warnings.warn("Warning: external csv file '{}' does not contain numeric " - "values in the column 'elevation'. Station data is discarded.". - format(station_path), - stacklevel=100) - schedule.station_data = station_data - - with open(path_to_csv, 'r', encoding='utf-8') as trips_file: - trip_reader = csv.DictReader(trips_file) - for trip in trip_reader: - rotation_id = trip['rotation_id'] - # trip gets reference to station data and calculates height diff during trip - # initialization. Could also get the height difference from here on - # get average hour of trip if level of loading or temperature has to be read from - # auxiliary tabular data - arr_time = datetime.datetime.fromisoformat(trip["arrival_time"]) - dep_time = datetime.datetime.fromisoformat(trip["departure_time"]) - - # get average hour of trip and parse to string, since tabular data has strings - # as keys - hour = (dep_time + (arr_time - dep_time) / 2).hour - # Get height difference from station_data - try: - height_diff = station_data[trip["arrival_name"]]["elevation"] \ - - station_data[trip["departure_name"]]["elevation"] - except (KeyError, TypeError): - height_diff = 0 - trip["height_difference"] = height_diff - - # Get level of loading from trips.csv or from file - try: - # Clip level of loading to [0,1] - lol = max(0, min(float(trip["level_of_loading"]), 1)) - # In case of empty temperature column or no column at all - except (KeyError, ValueError): - lol = level_of_loading_dict[hour] - trip["level_of_loading"] = lol - - trip["distance"] = float(trip["distance"]) - trip["arrival_time"] = datetime.datetime.fromisoformat(trip["arrival_time"]) - trip["departure_time"] = datetime.datetime.fromisoformat(trip["departure_time"]) - - # Get temperature from trips.csv or from file - try: - # Cast temperature to float - temperature = float(trip["temperature"]) - # In case of empty temperature column or no column at all - except (KeyError, ValueError): - temperature = temperature_data_dict[hour] - trip["temperature"] = temperature - if rotation_id not in schedule.rotations.keys(): - schedule.rotations.update({ - rotation_id: Rotation(id=rotation_id, - vehicle_type=trip['vehicle_type'], - schedule=schedule)}) - schedule.rotations[rotation_id].add_trip(trip) - - # set charging type for all rotations without explicitly specified charging type. - # charging type may have been set above if a trip of a rotation has a specified - # charging type - for rot in schedule.rotations.values(): - if rot.charging_type is None: - rot.set_charging_type(ct=kwargs.get('preferred_charging_type', 'depb')) - - if kwargs.get("check_rotation_consistency"): - # check rotation expectations - inconsistent_rotations = cls.check_consistency(schedule) - if inconsistent_rotations: - # write errors to file - filepath = kwargs["output_directory"] / "inconsistent_rotations.csv" - with open(filepath, "w", encoding='utf-8') as f: - for rot_id, e in inconsistent_rotations.items(): - f.write(f"Rotation {rot_id}: {e}\n") - logging.error(f"Rotation {rot_id}: {e}") - if kwargs.get("skip_inconsistent_rotations"): - # remove this rotation from schedule - del schedule.rotations[rot_id] - elif kwargs.get("skip_inconsistent_rotations"): - warnings.warn("Option skip_inconsistent_rotations ignored, " - "as check_rotation_consistency is not set to 'true'") - - return schedule - @classmethod def check_consistency(cls, schedule): """ Check rotation expectations. From 1276fa86ee6ff3dbea645e316a08261ec11931df Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Fri, 31 May 2024 10:01:01 +0200 Subject: [PATCH 25/68] Fix interpolation with duplicate values --- simba/util.py | 3 +++ tests/test_data_container.py | 0 tests/test_nd_interpolate.py | 7 +++++++ 3 files changed, 10 insertions(+) create mode 100644 tests/test_data_container.py diff --git a/simba/util.py b/simba/util.py index 3ae4e663..8fe1be73 100644 --- a/simba/util.py +++ b/simba/util.py @@ -182,6 +182,9 @@ def nd_interp(input_values, lookup_table): else: points.append(row) + # Make points unique + points = [tuple(p) for p in points] + points = list(set(points)) # interpolate between points that differ only in current dimension for i, x in enumerate(input_values): new_points = [] diff --git a/tests/test_data_container.py b/tests/test_data_container.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_nd_interpolate.py b/tests/test_nd_interpolate.py index 4b23677b..63e259e1 100644 --- a/tests/test_nd_interpolate.py +++ b/tests/test_nd_interpolate.py @@ -37,6 +37,8 @@ def get_outer_point(table, dims_out_of_bound=1): point = () for dim in range(idims): offset = random.random() + if offset == 0: + offset = 0.1 if dim < dims_out_of_bound: # out of bounds # the point is randomly below the lower bounds or above the upper bound, depending @@ -55,6 +57,7 @@ def get_outer_point(table, dims_out_of_bound=1): return point + class TestNdInterpol: random.seed(5) linear_function = None @@ -62,6 +65,10 @@ class TestNdInterpol: points_to_check = POINTS_TO_CHECK dim_amount = DIM_AMOUNT + def test_specific(self): + nd_interp((1, 2), [(1, 2, 3), (0, 2, 4), (1, 2, 3), (0, 2, 4)]) + nd_interp((5, 10), [(1, 2, 3), (0, 2, 4), (1, 2, 3), (0, 2, 4)]) + # some manual test for a simple data table, where interpolated values can be easily # calculated by hand def test_lookup_tables(self): From e24fc49446679ba15d60b93253feeda48ad7b4f4 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Fri, 31 May 2024 11:38:59 +0200 Subject: [PATCH 26/68] Refactor consumption calculation --- simba/__init__.py | 3 + simba/consumption.py | 119 +++------------------- simba/data_container.py | 24 +++-- simba/ids.py | 1 + simba/optimization.py | 4 +- simba/rotation.py | 18 +--- simba/schedule.py | 44 ++++++++- simba/simulate.py | 6 +- simba/trip.py | 29 ------ tests/helpers.py | 33 +++---- tests/test_consumption.py | 75 ++++---------- tests/test_optimization.py | 6 +- tests/test_rotation.py | 14 ++- tests/test_schedule.py | 152 ++++++++++++----------------- tests/test_soc_dispatcher.py | 3 +- tests/test_station_optimization.py | 15 ++- 16 files changed, 195 insertions(+), 351 deletions(-) diff --git a/simba/__init__.py b/simba/__init__.py index e69de29b..99a4cccd 100644 --- a/simba/__init__.py +++ b/simba/__init__.py @@ -0,0 +1,3 @@ +from simba.schedule import Schedule +from simba.rotation import Rotation +from simba.trip import Trip \ No newline at end of file diff --git a/simba/consumption.py b/simba/consumption.py index 7e27467a..3fade0e4 100644 --- a/simba/consumption.py +++ b/simba/consumption.py @@ -8,34 +8,11 @@ class Consumption: - def __init__(self, vehicle_types, **kwargs) -> None: - # load temperature of the day, now dummy winter day - self.temperatures_by_hour = {} - - temperature_file_path = kwargs.get("outside_temperatures", None) - # parsing the Temperature to a dict - if temperature_file_path is not None: - with open(temperature_file_path, encoding='utf-8') as f: - delim = util.get_csv_delim(temperature_file_path) - reader = csv.DictReader(f, delimiter=delim) - for row in reader: - self.temperatures_by_hour.update({int(row['hour']): float(row['temperature'])}) - - lol_file_path = kwargs.get("level_of_loading_over_day", None) - # parsing the level of loading to a dict - if lol_file_path is not None: - with open(lol_file_path, encoding='utf-8') as f: - delim = util.get_csv_delim(lol_file_path) - reader = csv.DictReader(f, delimiter=delim) - self.lol_by_hour = {} - for row in reader: - self.lol_by_hour.update({int(row['hour']): float(row['level_of_loading'])}) - - self.consumption_files = {} - self.vehicle_types = vehicle_types - - def calculate_consumption(self, time, distance, vehicle_type, charging_type, temp=None, - height_difference=0, level_of_loading=None, mean_speed=18): + def __init__(self) -> None: + self.consumption_interpolation = {} + + def __call__(self, distance, vehicle_type, vehicle_info, temp, + height_difference, level_of_loading, mean_speed): """ Calculates consumed amount of energy for a given distance. :param time: The date and time at which the trip ends @@ -44,9 +21,8 @@ def calculate_consumption(self, time, distance, vehicle_type, charging_type, tem :type distance: float :param vehicle_type: The vehicle type for which to calculate consumption :type vehicle_type: str - :param charging_type: Charging type for the trip. Consumption differs between - distinct types. - :type charging_type: str + :param vehicle_info: vehicle type information including mileage / consumption path + :type vehicle_info: dict :param temp: Temperature outside of the bus in °Celsius :type temp: float :param height_difference: difference in height between stations in meters- @@ -61,38 +37,25 @@ def calculate_consumption(self, time, distance, vehicle_type, charging_type, tem :rtype: (float, float) """ - assert self.vehicle_types.get(vehicle_type, {}).get(charging_type), ( - f"Combination of vehicle type {vehicle_type} and {charging_type} not defined.") - - vehicle_info = self.vehicle_types[vehicle_type][charging_type] - # in case a constant mileage is provided if isinstance(vehicle_info['mileage'], (int, float)): consumed_energy = vehicle_info['mileage'] * distance / 1000 delta_soc = -1 * (consumed_energy / vehicle_info["capacity"]) return consumed_energy, delta_soc - # if no specific Temperature is given, lookup temperature - if temp is None: - temp = self.get_temperature(time, vehicle_info) - - # if no specific LoL is given, lookup LoL - if level_of_loading is None: - level_of_loading = self.get_level_of_loading(time, vehicle_info) - # load consumption csv consumption_path = str(vehicle_info["mileage"]) - # consumption_files holds interpol functions of csv files which are called directly + # consumption_interpolation holds interpol functions of csv files which are called directly # try to use the interpol function. If it does not exist yet its created in except case. consumption_lookup_name = self.get_consumption_lookup_name(consumption_path, vehicle_type) # This lookup includes the vehicle type. If the consumption data did not include vehicle # types this key will not be found. In this case use the file path instead try: - interpol_function = self.consumption_files[consumption_lookup_name] + interpol_function = self.consumption_interpolation[consumption_lookup_name] except KeyError: - interpol_function = self.consumption_files[consumption_path] + interpol_function = self.consumption_interpolation[consumption_path] mileage = interpol_function( this_incline=height_difference / distance, this_temp=temp, @@ -111,7 +74,7 @@ def create_from_data_container(data_container: DataContainer): :type data_container: DataContainer """ # setup consumption calculator that can be accessed by all trips - consumption = Consumption(data_container.vehicle_types_data) + consumption = Consumption() for name, df in data_container.consumption_data.items(): consumption.set_consumption_interpolation(name, df) return consumption @@ -136,15 +99,15 @@ def set_consumption_interpolation(self, consumption_lookup_name: str, df: pd.Dat df_vt = df.loc[mask, [INCLINE, SPEED, LEVEL_OF_LOADING, T_AMB, CONSUMPTION]] interpol_function = self.get_nd_interpolation(df_vt) vt_specific_name = self.get_consumption_lookup_name(consumption_lookup_name, vt) - if vt_specific_name in self.consumption_files: + if vt_specific_name in self.consumption_interpolation: warnings.warn("Overwriting exising consumption function") - self.consumption_files.update({vt_specific_name: interpol_function}) + self.consumption_interpolation.update({vt_specific_name: interpol_function}) return interpol_function = self.get_nd_interpolation(df) - if consumption_lookup_name in self.consumption_files: + if consumption_lookup_name in self.consumption_interpolation: warnings.warn("Overwriting exising consumption function") - self.consumption_files.update({consumption_lookup_name: interpol_function}) + self.consumption_interpolation.update({consumption_lookup_name: interpol_function}) def get_nd_interpolation(self, df): """ @@ -169,58 +132,6 @@ def interpol_function(this_incline, this_temp, this_lol, this_speed): return interpol_function - def get_temperature(self, time, vehicle_info): - """ - Get temperature for the given time. - - :param time: time of the lookup. - :type time: datetime.datetime - :param vehicle_info: Information about the vehicle. - :type vehicle_info: dict - :return: Temperature. - :rtype: float - :raises AttributeError: if temperature data is not available. - :raises KeyError: if temperature data is not available for the given hour. - """ - - try: - return self.temperatures_by_hour[time.hour] - except AttributeError as e: - raise AttributeError( - "Neither of these conditions is met:\n" - "1. Temperature data is available for every trip through the trips file " - "or a temperature over day file.\n" - f"2. A constant mileage for the vehicle " - f"{vehicle_info['mileage']} is provided." - ) from e - except KeyError as e: - raise KeyError(f"No temperature data for the hour {time.hour} is provided") from e - - def get_level_of_loading(self, time, vehicle_info): - """ - Get level of loading for the given time. - - :param time: time of the lookup. - :type time: datetime.datetime - :param vehicle_info: Information about the vehicle. - :type vehicle_info: dict - :return: Level of loading. - :rtype: float - :raises AttributeError: if level of loading data is not available. - :raises KeyError: if level of loading data is not available for the given hour. - """ - try: - return self.lol_by_hour[time.hour] - except AttributeError as e: - raise AttributeError( - "Neither of these conditions is met:\n" - "1. Level of loading data is available for every trip through the trips file " - "or a level of loading over day file.\n" - f"2. A constant mileage for the vehicle " - f"{vehicle_info['mileage']} is provided." - ) from e - except KeyError as e: - raise KeyError(f"No level of loading for the hour {time.hour} is provided") from e def get_consumption_lookup_name(self, consumption_path, vehicle_type): """ diff --git a/simba/data_container.py b/simba/data_container.py index f5e83ec4..f857b0b3 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -9,7 +9,7 @@ import pandas as pd from simba import util -from simba.ids import INCLINE, LEVEL_OF_LOADING, SPEED, T_AMB, CONSUMPTION +from simba.ids import INCLINE, LEVEL_OF_LOADING, SPEED, T_AMB, TEMPERATURE, CONSUMPTION class DataContainer: @@ -57,8 +57,8 @@ def add_trip_data_from_csv(self, file_path: Path) -> 'DataContainer': trip_d = dict(trip) trip_d["arrival_time"] = datetime.datetime.fromisoformat(trip["arrival_time"]) trip_d["departure_time"] = datetime.datetime.fromisoformat(trip["departure_time"]) - trip_d["level_of_loading"] = cast_float_or_none(trip.get("level_of_loading")) - trip_d["temperature"] = cast_float_or_none(trip.get("temperature")) + trip_d[LEVEL_OF_LOADING] = cast_float_or_none(trip.get(LEVEL_OF_LOADING)) + trip_d[TEMPERATURE] = cast_float_or_none(trip.get(TEMPERATURE)) trip_d["distance"] = float(trip["distance"]) self.trip_data.append(trip_d) return self @@ -122,17 +122,15 @@ def add_station_geo_data_from_csv(self, file_path: Path) -> 'DataContainer': "lat": float(row.get('lat', 0)), "long": float(row.get('long', 0)), } - except FileNotFoundError or KeyError: - logging.warning("Warning: external csv file '{}' not found or not named properly " - "(Needed column names are 'Endhaltestelle' and 'elevation')". - format(file_path), - stacklevel=100) + except (FileNotFoundError, KeyError): + logging.warning("External csv file '{}' not found or not named properly " + "(Needed column names are 'Endhaltestelle' and 'elevation')". + format(file_path), + stacklevel=100) except ValueError: - logging.warning("Warning: external csv file '{}' does not contain numeric " - "values in the column 'elevation'. Station data is discarded.". - format(file_path), - stacklevel=100) - + logging.warning("External csv file '{}' should only contain numeric data". + format(file_path), + stacklevel=100) return self def add_level_of_loading_data(self, data: dict) -> 'DataContainer': diff --git a/simba/ids.py b/simba/ids.py index 865293f3..38846d7c 100644 --- a/simba/ids.py +++ b/simba/ids.py @@ -5,3 +5,4 @@ SPEED = "mean_speed_kmh" CONSUMPTION = "consumption_kwh_per_km" VEHICLE_TYPE = "vehicle_type" +TEMPERATURE = "temperature" diff --git a/simba/optimization.py b/simba/optimization.py index 1273ac13..f1d00e98 100644 --- a/simba/optimization.py +++ b/simba/optimization.py @@ -312,7 +312,7 @@ def recombination(schedule, args, trips, depot_trips): # calculate consumption for initial trip soc = args.desired_soc_deps # vehicle leaves depot with this soc - rotation.calculate_consumption() + schedule.calculate_rotation_consumption(rotation) soc += rotation.trips[0].delta_soc # new soc after initial trip if rot_counter > 0: @@ -345,7 +345,7 @@ def recombination(schedule, args, trips, depot_trips): # rotation.add_trip needs dict, but consumption calculation is better done on Trip obj: # create temporary depot trip object for consumption calculation tmp_trip = Trip(rotation, **depot_trip) - tmp_trip.calculate_consumption() + schedule.calculate_trip_consumption(tmp_trip) if soc >= -(trip.delta_soc + tmp_trip.delta_soc): # next trip is possible: add trip, use info from original trip trip_dict = vars(trip) diff --git a/simba/rotation.py b/simba/rotation.py index be59afbe..22da36d9 100644 --- a/simba/rotation.py +++ b/simba/rotation.py @@ -74,25 +74,11 @@ def add_trip(self, trip): self.set_charging_type(charging_type) elif self.charging_type == charging_type: # same CT as other trips: just add trip consumption - self.consumption += new_trip.calculate_consumption() + self.consumption += self.schedule.calculate_trip_consumption(new_trip) else: # different CT than rotation: error raise Exception(f"Two trips of rotation {self.id} have distinct charging types") - def calculate_consumption(self): - """ Calculate consumption of this rotation and all its trips. - - :return: Consumption of rotation [kWh] - :rtype: float - """ - rotation_consumption = 0 - for trip in self.trips: - rotation_consumption += trip.calculate_consumption() - - self.consumption = rotation_consumption - - return rotation_consumption - def set_charging_type(self, ct): """ Change charging type of either all or specified rotations. @@ -112,7 +98,7 @@ def set_charging_type(self, ct): old_consumption = self.consumption self.charging_type = ct # consumption may have changed with new charging type - self.consumption = self.calculate_consumption() + self.consumption = self.schedule.calculate_rotation_consumption(self) # recalculate schedule consumption: update for new rotation consumption self.schedule.consumption += self.consumption - old_consumption diff --git a/simba/schedule.py b/simba/schedule.py index 338260cb..24d311b9 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -8,9 +8,11 @@ from typing import Dict, Type, Iterable import simba.rotation +from simba.consumption import Consumption from simba.data_container import DataContainer from simba import util, optimizer_util from simba.rotation import Rotation +from simba.trip import Trip from spice_ev.components import VehicleType from spice_ev.scenario import Scenario @@ -65,6 +67,8 @@ def __init__(self, vehicle_types, stations, **kwargs): self.vehicle_types = vehicle_types self.original_rotations = None self.station_data = None + + self.consumption_calculator: Consumption = None self.soc_dispatcher: SocDispatcher = None self.data_container: DataContainer = None # mandatory config parameters @@ -89,8 +93,13 @@ def from_datacontainer(cls, data: DataContainer, args): schedule = cls(data.vehicle_types_data, data.stations_data, **vars(args)) schedule.data_container = data + + # Add geo data to schedule schedule.station_data = data.station_geo_data + # Add consumption calculator to trip class + schedule.consumption_calculator = Consumption.create_from_data_container(data) + for trip in data.trip_data: rotation_id = trip['rotation_id'] # trip gets reference to station data and calculates height diff during trip @@ -478,7 +487,7 @@ def assign_vehicles_w_adaptive_soc(self, args): standing_vehicles_w_soc = [(*v, rot_idx) for v, rot_idx in zip(standing_vehicles, socs)] standing_vehicles_w_soc = sorted(standing_vehicles_w_soc, key=lambda x: x[-1]) - consumption_soc = rot.calculate_consumption() / self.vehicle_types[vt][ct]["capacity"] + consumption_soc = self.calculate_rotation_consumption(rot) / self.vehicle_types[vt][ct]["capacity"] for vehicle_id, depot, soc in standing_vehicles_w_soc: # end soc of vehicle if it services this rotation end_soc = soc - consumption_soc @@ -566,10 +575,39 @@ def calculate_consumption(self): """ self.consumption = 0 for rot in self.rotations.values(): - self.consumption += rot.calculate_consumption() - + self.consumption += self.calculate_rotation_consumption(rot) return self.consumption + def calculate_rotation_consumption(self, rotation: Rotation): + rotation.consumption = 0 + for trip in rotation.trips: + rotation.consumption += self.calculate_trip_consumption(trip) + return rotation.consumption + + def calculate_trip_consumption(self, trip: Trip): + """ Compute consumption for this trip. + + :return: Consumption of trip [kWh] + :rtype: float + :raises with_traceback: if consumption cannot be constructed + """ + vehicle_type = trip.rotation.vehicle_type + vehicle_info = self.vehicle_types[vehicle_type][trip.rotation.charging_type] + try: + trip.consumption, trip.delta_soc = self.consumption_calculator( + distance=trip.distance, + vehicle_type=trip.rotation.vehicle_type, + vehicle_info=vehicle_info, + temp=trip.temperature, + height_difference=trip.height_difference, + level_of_loading=trip.level_of_loading, + mean_speed=trip.mean_speed) + except AttributeError as e: + raise Exception( + 'To calculate consumption, a consumption object needs to be constructed' + ' and linked to the Schedule.').with_traceback(e.__traceback__) + return trip.consumption + def get_departure_of_first_trip(self): """ Finds earliest departure time among all rotations. diff --git a/simba/simulate.py b/simba/simulate.py index c21b6cd0..df6d70bd 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -3,13 +3,11 @@ from copy import deepcopy from simba import report, optimization, optimizer_util -from simba.consumption import Consumption from simba.data_container import DataContainer from simba.costs import calculate_costs from simba.optimizer_util import read_config as read_optimizer_config from simba.schedule import Schedule from simba.station_optimization import run_optimization -from simba.trip import Trip def simulate(args): @@ -24,7 +22,7 @@ def simulate(args): :return: final schedule and scenario :rtype: tuple """ - # The data stores various input data. + # The DataContainer stores various input data. data_container = DataContainer().fill_with_args(args) schedule, args = pre_simulation(args, data_container) @@ -48,8 +46,6 @@ def pre_simulation(args, data_container: DataContainer): """ # Deepcopy args so original args do not get mutated, i.e. deleted args = deepcopy(args) - # Add consumption calculator to trip class - Trip.consumption = Consumption.create_from_data_container(data_container) # generate schedule from csv schedule = Schedule.from_datacontainer(data_container, args) diff --git a/simba/trip.py b/simba/trip.py index 7ceea773..89bd3ebb 100644 --- a/simba/trip.py +++ b/simba/trip.py @@ -1,11 +1,6 @@ from datetime import timedelta -import simba.consumption - - class Trip: - consumption: simba.consumption.Consumption = None - def __init__(self, rotation, departure_time, departure_name, arrival_time, arrival_name, distance, temperature, level_of_loading, height_difference, **kwargs): @@ -36,27 +31,3 @@ def __init__(self, rotation, departure_time, departure_name, self.consumption = None # kWh self.delta_soc = None - def calculate_consumption(self): - """ Compute consumption for this trip. - - :return: Consumption of trip [kWh] - :rtype: float - :raises with_traceback: if consumption cannot be constructed - """ - - try: - self.consumption, self.delta_soc = Trip.consumption.calculate_consumption( - self.arrival_time, - self.distance, - self.rotation.vehicle_type, - self.rotation.charging_type, - temp=self.temperature, - height_difference=self.height_difference, - level_of_loading=self.level_of_loading, - mean_speed=self.mean_speed) - except AttributeError as e: - raise Exception( - 'To calculate consumption, a consumption object needs to be constructed' - ' and linked to Trip class.').with_traceback(e.__traceback__) - - return self.consumption diff --git a/tests/helpers.py b/tests/helpers.py index b0a1fa59..694afcff 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,21 +1,20 @@ """ Reusable functions that support tests """ -from argparse import Namespace +import sys +from copy import deepcopy -from simba import schedule, trip, consumption, util +from simba import trip, consumption, util from simba.data_container import DataContainer +from simba.simulate import pre_simulation +from tests.conftest import example_root -def generate_basic_schedule(): - schedule_path = 'data/examples/trips_example.csv' - station_path = 'data/examples/electrified_stations.json' - temperature_path = 'data/examples/default_temp_winter.csv' - lol_path = 'data/examples/default_level_of_loading_over_day.csv' - with open("data/examples/vehicle_types.json", 'r', encoding='utf-8') as f: - vehicle_types = util.uncomment_json_file(f) - - initialize_consumption(vehicle_types) - +def generate_basic_schedule(cache=[None]): + if cache[0] is not None: + return deepcopy(cache[0]) + sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] + args = util.get_args() + args.preferred_charging_type = "oppb" mandatory_args = { "min_recharge_deps_oppb": 0, "min_recharge_deps_depb": 0, @@ -26,11 +25,11 @@ def generate_basic_schedule(): "cs_power_deps_oppb": 150, "desired_soc_deps": 1, } - generated_schedule = schedule.Schedule.from_csv( - schedule_path, vehicle_types, station_path, **mandatory_args, - outside_temperature_over_day_path=temperature_path, level_of_loading_over_day_path=lol_path) - generated_schedule.assign_vehicles(Namespace(**mandatory_args)) - return generated_schedule + vars(args).update(mandatory_args) + data_container = DataContainer().fill_with_args(args) + generated_schedule, args = pre_simulation(args, data_container) + cache[0] = deepcopy((generated_schedule, args)) + return generated_schedule, args def initialize_consumption(vehicle_types): diff --git a/tests/test_consumption.py b/tests/test_consumption.py index 24d1451c..a8b9fa0f 100644 --- a/tests/test_consumption.py +++ b/tests/test_consumption.py @@ -15,19 +15,17 @@ def test_calculate_consumption(self, tmp_path): :param tmp_path: pytest fixture to create a temporary path """ schedule, scenario, _ = BasicSchedule().basic_run() - trip = next(iter(schedule.rotations.values())).trips.pop(0) - consumption = trip.__class__.consumption - consumption.temperatures_by_hour = {hour: hour * 2 - 15 for hour in range(0, 24)} - time = datetime(year=2023, month=1, day=1, hour=1) + consumption_calculator = schedule.consumption_calculator dist = 10 - vehicle = next(iter(consumption.vehicle_types.items())) + vehicle = next(iter(schedule.vehicle_types.items())) vehicle_type = vehicle[0] charging_type = next(iter(vehicle[1].keys())) + vehicle_info = schedule.vehicle_types[vehicle_type][charging_type] # check distance scaling def calc_c(distance): - return consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=10, height_difference=0, + return consumption_calculator( + distance, vehicle_type, vehicle_info, temp=10, height_difference=0, level_of_loading=0, mean_speed=18)[0] assert calc_c(dist) * 2 == calc_c(dist * 2) @@ -38,7 +36,7 @@ def calc_c(distance): def true_cons(lol, incline, speed, t_amb): return lol + incline + speed / 10 + (t_amb - 20) / 10 - # apply the formula on the consumption file + # apply the formula on the consumption dataframe consumption_df = pd.read_csv(self.consumption_path) consumption_col = consumption_df["consumption_kwh_per_km"].view() lol_col = consumption_df["level_of_loading"] @@ -50,7 +48,7 @@ def true_cons(lol, incline, speed, t_amb): # save the file in a temp folder and use from now on vehicle[1][charging_type]["mileage"] = "new_consumption" - consumption.set_consumption_interpolation(vehicle[1][charging_type]["mileage"], + consumption_calculator.set_consumption_interpolation(vehicle[1][charging_type]["mileage"], consumption_df) lol = 0.5 @@ -59,25 +57,25 @@ def true_cons(lol, incline, speed, t_amb): t_amb = 20 distance = 1000 # 1000m =1km, since true_cons give the consumption per 1000 m - # Check various inputs, which need interpolation. Inputs have to be inside of the data, i.e. + # Check various inputs, which need interpolation. Inputs have to be inside the data, i.e. # not out of bounds - assert true_cons(lol, incline, speed, t_amb) == consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, + assert true_cons(lol, incline, speed, t_amb) == consumption_calculator( + distance, vehicle_type, vehicle_info, temp=t_amb, height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] incline = 0.02 - assert true_cons(lol, incline, speed, t_amb) == consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, + assert true_cons(lol, incline, speed, t_amb) == consumption_calculator( + distance, vehicle_type, vehicle_info, temp=t_amb, height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] t_amb = 15 - assert true_cons(lol, incline, speed, t_amb) == consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, + assert true_cons(lol, incline, speed, t_amb) == consumption_calculator( + distance, vehicle_type, vehicle_info, temp=t_amb, height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] lol = 0.1 - assert true_cons(lol, incline, speed, t_amb) == consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, + assert true_cons(lol, incline, speed, t_amb) == consumption_calculator( + distance, vehicle_type, vehicle_info, temp=t_amb, height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] # check for out of bounds consumption. Max consumption in the table is 6.6. @@ -85,43 +83,6 @@ def true_cons(lol, incline, speed, t_amb): incline = 99999 lol = 99999 speed = 99999 - assert consumption.calculate_consumption( - time, distance, vehicle_type, charging_type, temp=t_amb, + assert consumption_calculator( + distance, vehicle_type, vehicle_info, temp=t_amb, height_difference=incline * distance, level_of_loading=lol, mean_speed=speed)[0] < 6.7 - - # check temperature default runs without errors when temp is None - consumption.calculate_consumption( - time, dist, vehicle_type, charging_type, temp=None, height_difference=0, - level_of_loading=0, mean_speed=18)[0] - - # check temperature default from temperature time series error throwing - last_hour = 12 - consumption.temperatures_by_hour = {hour: hour * 2 - 15 for hour in range(0, last_hour)} - time = datetime(year=2023, month=1, day=1, hour=last_hour + 2) - with pytest.raises(KeyError): - consumption.calculate_consumption( - time, dist, vehicle_type, charging_type, temp=None, height_difference=0, - level_of_loading=0, mean_speed=18)[0] - - del consumption.temperatures_by_hour - with pytest.raises(AttributeError): - consumption.calculate_consumption( - time, dist, vehicle_type, charging_type, temp=None, height_difference=0, - level_of_loading=0, mean_speed=18)[0] - - # reset temperature_by_hour - consumption.temperatures_by_hour = {hour: hour * 2 - 15 for hour in range(0, 24)} - - # check level_of_loading default from level_of_loading time series error throwing - last_hour = 12 - consumption.lol_by_hour = {hour: hour * 2 - 15 for hour in range(0, last_hour)} - time = datetime(year=2023, month=1, day=1, hour=last_hour + 2) - with pytest.raises(KeyError): - consumption.calculate_consumption( - time, dist, vehicle_type, charging_type, temp=20, height_difference=0, - level_of_loading=None, mean_speed=18)[0] - del consumption.lol_by_hour - with pytest.raises(AttributeError): - consumption.calculate_consumption( - time, dist, vehicle_type, charging_type, temp=20, height_difference=0, - level_of_loading=None, mean_speed=18)[0] diff --git a/tests/test_optimization.py b/tests/test_optimization.py index 831db395..32836beb 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -7,7 +7,7 @@ class TestOptimization: def test_prepare_trips(self): - schedule = generate_basic_schedule() + schedule, _ = generate_basic_schedule() for r in schedule.rotations.values(): r.set_charging_type("depb") trips, depot_trips = optimization.prepare_trips(schedule) @@ -48,7 +48,7 @@ def test_prepare_trips(self): pass def test_generate_depot_trip_data_dict(self): - schedule = generate_basic_schedule() + schedule, _ = generate_basic_schedule() trip1 = schedule.rotations["1"].trips[0] trip2 = schedule.rotations["1"].trips[-1] trip2.distance = trip1.distance / 2 @@ -81,7 +81,7 @@ def test_generate_depot_trip_data_dict(self): assert trip_dict["distance"] == DEFAULT_DISTANCE * 1000 def test_recombination(self): - schedule = generate_basic_schedule() + schedule, _ = generate_basic_schedule() for r in schedule.rotations.values(): r.set_charging_type("depb") args = Namespace(**({ diff --git a/tests/test_rotation.py b/tests/test_rotation.py index 4f93099e..76236d96 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -1,10 +1,13 @@ +from copy import deepcopy + +import pytest + from tests.helpers import generate_basic_schedule def test_set_charging_type(): - s = generate_basic_schedule() + s, _ = generate_basic_schedule() rot = list(s.rotations.values())[0] - # set different mileages for different charging types to make sure consumption is properly # calculated for vehicle_key, vehicle_class in s.vehicle_types.items(): @@ -13,17 +16,18 @@ def test_set_charging_type(): vehicle["mileage"] = 10 else: vehicle["mileage"] = 20 - rot.consumption = rot.calculate_consumption() + # set charging type to oppb rot.set_charging_type('oppb') assert rot.charging_type == 'oppb' # save the consumption of this type of charger - consumption_oppb = rot.consumption + consumption_oppb = s.calculate_rotation_consumption(rot) # set charging type to depb rot.set_charging_type('depb') assert rot.charging_type == 'depb' # check that the consumption changed due to the change in charging type. The proper calculation # of consumption is tested in test_consumption - assert rot.consumption != consumption_oppb + consumption_depb = s.calculate_rotation_consumption(rot) + assert consumption_depb * 2 == pytest.approx(consumption_oppb) diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 6500573d..af8b5b91 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -4,7 +4,6 @@ import pytest import sys import spice_ev.scenario as scenario -from spice_ev.util import set_options_from_config from simba.simulate import pre_simulation from tests.conftest import example_root, file_root @@ -27,40 +26,27 @@ class BasicSchedule: temperature_path = example_root / 'default_temp_winter.csv' lol_path = example_root / 'default_level_of_loading_over_day.csv' vehicle_types_path = example_root / "vehicle_types.json" - with open(example_root / "electrified_stations.json", "r", encoding='utf-8') as file: - electrified_stations = util.uncomment_json_file(file) - - with open(vehicle_types_path, "r", encoding='utf-8') as file: - vehicle_types = util.uncomment_json_file(file) - + electrified_stations_path = example_root / "electrified_stations.json" path_to_all_station_data = example_root / "all_stations.csv" @pytest.fixture def default_schedule_arguments(self): - arguments = {"path_to_csv": None, - "vehicle_types": self.vehicle_types, - "stations": self.electrified_stations, + arguments = {"vehicle_types_path": self.vehicle_types_path, + "electrified_stations_path": self.electrified_stations_path, "station_data_path": self.path_to_all_station_data, "outside_temperature_over_day_path": self.temperature_path, "level_of_loading_over_day_path": self.lol_path } - arguments.update(**mandatory_args) return arguments def basic_run(self): - """Returns a schedule, scenario and args after running SimBA. + """Returns a schedule, scenario and args after running SimBA from config. :return: schedule, scenario, args """ # set the system variables to imitate the console call with the config argument. # first element has to be set to something or error is thrown sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] args = util.get_args() - args.config = example_root / "simba.cfg" - args.days = None - args.seed = 5 - set_options_from_config(args, verbose=False) - args.ALLOW_NEGATIVE_SOC = True - args.attach_vehicle_soc = True data_container = DataContainer().fill_with_args(args) sched, args = pre_simulation(args, data_container) @@ -73,35 +59,38 @@ def test_mandatory_options_exit(self): """ Check if the schedule creation properly throws an error in case of missing mandatory options """ - args = mandatory_args.copy() + sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] + args = util.get_args() + data_container = DataContainer().fill_with_args(args) + for key in mandatory_args.keys(): - value = args.pop(key) + args = util.get_args() + args.__delattr__(key) with pytest.raises(Exception): # schedule creation without mandatory arg - schedule.Schedule(self.vehicle_types, self.electrified_stations, **args) - args[key] = value + schedule.Schedule.from_datacontainer(data_container, args) - def test_station_data_reading(self, default_schedule_arguments): + def test_station_data_reading(self, caplog): """ Test if the reading of the geo station data works and outputs warnings in case the data was problematic, e.g. not numeric or not existent :param default_schedule_arguments: basic arguments the schedule needs for creation """ - initialize_consumption(self.vehicle_types) + sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] + args = util.get_args() + data_container = DataContainer().fill_with_args(args) - default_schedule_arguments["path_to_csv"] = example_root / "trips_example.csv" - generated_schedule = schedule.Schedule.from_csv(**default_schedule_arguments) + generated_schedule, args = pre_simulation(args, data_container) assert generated_schedule.station_data is not None # check if reading a non valid station.csv throws warnings - with pytest.warns(Warning) as record: - default_schedule_arguments["station_data_path"] = file_root / "not_existent_file" - schedule.Schedule.from_csv(**default_schedule_arguments) - assert len(record) == 1 + args.station_data_path = file_root / "not_existent_file" + data_container = DataContainer().fill_with_args(args) + assert len(caplog.records) == 1 - default_schedule_arguments["station_data_path"] = file_root / "not_numeric_stations.csv" - schedule.Schedule.from_csv(**default_schedule_arguments) - assert len(record) == 2 + args.station_data_path = file_root / "not_numeric_stations.csv" + data_container = DataContainer().fill_with_args(args) + assert len(caplog.records) == 2 def test_basic_run(self): """ Check if running a basic example works and if a scenario object is returned @@ -113,11 +102,13 @@ def test_assign_vehicles_fixed_recharge(self): """ Test if assigning vehicles works as intended using the fixed_recharge strategy """ - initialize_consumption(self.vehicle_types) + sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] + args = util.get_args() + args.input_schedule = file_root / "trips_assign_vehicles_extended.csv" + data_container = DataContainer().fill_with_args(args) + + generated_schedule, args = pre_simulation(args, data_container) - path_to_trips = file_root / "trips_assign_vehicles_extended.csv" - generated_schedule = schedule.Schedule.from_csv( - path_to_trips, self.vehicle_types, self.electrified_stations, **mandatory_args) all_rotations = [r for r in generated_schedule.rotations] args = Namespace(**{}) args.assign_strategy = "fixed_recharge" @@ -156,12 +147,13 @@ def test_assign_vehicles_fixed_recharge(self): def test_assign_vehicles_adaptive(self): """ Test if assigning vehicles works as intended using the adaptive strategy """ + sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] + args = util.get_args() + args.input_schedule = file_root / "trips_assign_vehicles_extended.csv" + data_container = DataContainer().fill_with_args(args) - initialize_consumption(self.vehicle_types) + generated_schedule, args = pre_simulation(args, data_container) - path_to_trips = file_root / "trips_assign_vehicles_extended.csv" - generated_schedule = schedule.Schedule.from_csv( - path_to_trips, self.vehicle_types, self.electrified_stations, **mandatory_args) args = Namespace(**{"desired_soc_deps": 1}) args.assign_strategy = None generated_schedule.assign_vehicles(args) @@ -208,13 +200,12 @@ def test_calculate_consumption(self, default_schedule_arguments): :param default_schedule_arguments: basic arguments the schedule needs for creation """ - # Changing self.vehicle_types can propagate to other tests - vehicle_types = deepcopy(self.vehicle_types) - initialize_consumption(vehicle_types) + sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] + args = util.get_args() + args.input_schedule = file_root / "trips_assign_vehicles.csv" + data_container = DataContainer().fill_with_args(args) - default_schedule_arguments["path_to_csv"] = file_root / "trips_assign_vehicles.csv" - default_schedule_arguments["vehicle_types"] = vehicle_types - generated_schedule = schedule.Schedule.from_csv(**default_schedule_arguments) + generated_schedule, args = pre_simulation(args, data_container) # set mileage to a constant mileage = 10 @@ -235,10 +226,11 @@ def test_get_common_stations(self, default_schedule_arguments): :param default_schedule_arguments: basic arguments the schedule needs for creation """ - initialize_consumption(self.vehicle_types) - - default_schedule_arguments["path_to_csv"] = file_root / "trips_assign_vehicles.csv" - generated_schedule = schedule.Schedule.from_csv(**default_schedule_arguments) + sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] + args = util.get_args() + args.input_schedule = file_root / "trips_assign_vehicles.csv" + data_container = DataContainer().fill_with_args(args) + generated_schedule, args = pre_simulation(args, data_container) common_stations = generated_schedule.get_common_stations(only_opps=False) assert len(common_stations["1"]) == 0 @@ -260,8 +252,14 @@ def test_get_negative_rotations(self): neg_rots = sched.get_negative_rotations(scen) assert ['11'] == neg_rots - def test_rotation_filter(self, tmp_path, default_schedule_arguments): - s = schedule.Schedule(**default_schedule_arguments) + def test_rotation_filter(self, tmp_path): + sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] + args = util.get_args() + args.input_schedule = file_root / "trips_assign_vehicles.csv" + data_container = DataContainer().fill_with_args(args) + + s, args = pre_simulation(args, data_container) + args = Namespace(**{ "rotation_filter_variable": None, "rotation_filter": None, @@ -329,23 +327,9 @@ def test_scenario_with_feed_in(self, default_schedule_arguments): """ sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] args = util.get_args() - args.config = example_root / "simba.cfg" - - args.days = None - args.seed = 5 - - args.input_schedule = example_root / "trips_example.csv" - args.electrified_stations_path = example_root / "electrified_stations.json" - args.station_data_path = example_root / "all_stations.csv" - args.vehicle_type_path = self.vehicle_types_path - args.level_of_loading_over_day_path = self.lol_path - args.outside_temperature_over_day_path = self.temperature_path - data_container = DataContainer().fill_with_args(args) generated_schedule, args = pre_simulation(args, data_container) - args.ALLOW_NEGATIVE_SOC = True - args.attach_vehicle_soc = True scen = generated_schedule.generate_scenario(args) assert "Station-0" in scen.components.photovoltaics assert "Station-3" in scen.components.photovoltaics @@ -353,7 +337,7 @@ def test_scenario_with_feed_in(self, default_schedule_arguments): assert scen.components.batteries["Station-0 storage"].capacity == 300 assert scen.components.batteries["Station-0 storage"].efficiency == 0.95 assert scen.components.batteries["Station-0 storage"].min_charging_power == 0 - generated_schedule.assign_vehicles(args) + scen = generated_schedule.run(args) assert type(scen) is scenario.Scenario @@ -375,18 +359,18 @@ def test_scenario_with_feed_in(self, default_schedule_arguments): else: assert 0, "No error despite wrong file paths" - def test_schedule_from_csv(self): - generated_schedule = generate_basic_schedule() + def test_schedule_from_datacontainer(self): + generated_schedule, _ = generate_basic_schedule() assert len(generated_schedule.rotations) == 8 assert type(generated_schedule) is schedule.Schedule def test_consistency(self): - sched = generate_basic_schedule() + sched, _ = generate_basic_schedule() # check if no error is thrown in the basic case assert len(schedule.Schedule.check_consistency(sched)) == 0 error = "Trip time is negative" - sched = generate_basic_schedule() + sched, _ = generate_basic_schedule() faulty_rot = list(sched.rotations.values())[0] faulty_trip = faulty_rot.trips[0] # create error through moving trip arrival 1 day before departure @@ -394,7 +378,7 @@ def test_consistency(self): assert schedule.Schedule.check_consistency(sched)["1"] == error error = "Break time is negative" - sched = generate_basic_schedule() + sched, _ = generate_basic_schedule() faulty_rot = list(sched.rotations.values())[0] faulty_trip = faulty_rot.trips[1] # create error through moving trip departure before last arrival @@ -402,14 +386,14 @@ def test_consistency(self): assert schedule.Schedule.check_consistency(sched)["1"] == error error = "Trips are not sequential" - sched = generate_basic_schedule() + sched, _ = generate_basic_schedule() faulty_rot = list(sched.rotations.values())[0] faulty_rot.trips[1].arrival_name = "foo" faulty_rot.trips[0].departure_name = "bar" assert schedule.Schedule.check_consistency(sched)["1"] == error error = "Start and end of rotation differ" - sched = generate_basic_schedule() + sched, _ = generate_basic_schedule() faulty_rot = list(sched.rotations.values())[0] departure_trip = list(faulty_rot.trips)[0] departure_trip.departure_name = "foo" @@ -419,28 +403,28 @@ def test_consistency(self): error = "Rotation data differs from trips data" # check arrival data in rotation - sched = generate_basic_schedule() + sched, _ = generate_basic_schedule() faulty_rot = list(sched.rotations.values())[0] faulty_rot.trips[-1].arrival_name = "foo" faulty_rot.trips[0].departure_name = "foo" faulty_rot.arrival_name = "bar" assert schedule.Schedule.check_consistency(sched)["1"] == error - sched = generate_basic_schedule() + sched, _ = generate_basic_schedule() faulty_rot = list(sched.rotations.values())[0] arrival_trip = faulty_rot.trips[-1] faulty_rot.arrival_time = arrival_trip.arrival_time - timedelta(minutes=1) assert schedule.Schedule.check_consistency(sched)["1"] == error # check departure data in rotation - sched = generate_basic_schedule() + sched, _ = generate_basic_schedule() faulty_rot = list(sched.rotations.values())[0] faulty_rot.trips[-1].arrival_name = "foo" faulty_rot.trips[0].departure_name = "foo" faulty_rot.departure_name = "bar" assert schedule.Schedule.check_consistency(sched)["1"] == error - sched = generate_basic_schedule() + sched, _ = generate_basic_schedule() faulty_rot = list(sched.rotations.values())[0] departure_trip = faulty_rot.trips[0] faulty_rot.departure_time = departure_trip.departure_time - timedelta(minutes=1) @@ -449,9 +433,7 @@ def test_consistency(self): def test_peak_load_window(self): # generate events to lower GC max power during peak load windows # setup basic schedule (reuse during test) - generated_schedule = generate_basic_schedule() - sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] - args = util.get_args() + generated_schedule, args = generate_basic_schedule() generated_schedule.init_soc_dispatcher(args) for station in generated_schedule.stations.values(): station["gc_power"] = 1000 @@ -516,11 +498,7 @@ def count_max_power_events(scenario): def test_generate_price_lists(self): # setup basic schedule - generated_schedule = generate_basic_schedule() - sys.argv = ["", "--config", str(example_root / "simba.cfg")] - args = util.get_args() - - generated_schedule.init_soc_dispatcher(args) + generated_schedule, args = generate_basic_schedule() # only test individual price CSV and random price generation args.include_price_csv = None diff --git a/tests/test_soc_dispatcher.py b/tests/test_soc_dispatcher.py index 939eb810..c32b38e1 100644 --- a/tests/test_soc_dispatcher.py +++ b/tests/test_soc_dispatcher.py @@ -60,13 +60,12 @@ def basic_run(self): sched.rotations["21"].departure_time += dt sched.rotations["21"].arrival_time += dt - for v_type in Trip.consumption.vehicle_types.values(): + for v_type in sched.vehicle_types.values(): for charge_type in v_type.values(): charge_type["mileage"] = 1 # calculate consumption of all trips sched.calculate_consumption() - sched.rotations["21"].consumption # Create soc dispatcher sched.init_soc_dispatcher(args) diff --git a/tests/test_station_optimization.py b/tests/test_station_optimization.py index d1ad25c9..2f68067c 100644 --- a/tests/test_station_optimization.py +++ b/tests/test_station_optimization.py @@ -10,7 +10,9 @@ from simba import station_optimizer import simba.optimizer_util as opt_util +from simba.data_container import DataContainer from simba.schedule import Schedule +from simba.simulate import pre_simulation from simba.station_optimization import run_optimization import simba.util as util from tests.helpers import initialize_consumption @@ -113,15 +115,12 @@ def basic_run(self, trips_file_name="trips.csv"): :type trips_file_name: str :return: schedule, scenario""" - path_to_trips = file_root / trips_file_name - sys.argv = ["foo", "--config", str(self.tmp_path / "simba.cfg")] + sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] args = util.get_args() - args.input_schedule = path_to_trips - initialize_consumption(self.vehicle_types) - args2 = copy(args) - generated_schedule = Schedule.from_csv(path_to_trips, self.vehicle_types, - self.electrified_stations, - **vars(args2)) + args.input_schedule = file_root / trips_file_name + args.preferred_charging_type = "oppb" + data_container = DataContainer().fill_with_args(args) + generated_schedule, args = pre_simulation(args, data_container) # Create soc dispatcher generated_schedule.init_soc_dispatcher(args) generated_schedule.assign_vehicles(args) From 95c1675678b1ace9661b1a73f71bac3c8d15dbcd Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Fri, 31 May 2024 15:29:56 +0200 Subject: [PATCH 27/68] Fix tests --- .../optimization/trips_for_optimizer.csv | 6 +- tests/test_station_optimization.py | 81 +++++++++++++------ 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/tests/test_input_files/optimization/trips_for_optimizer.csv b/tests/test_input_files/optimization/trips_for_optimizer.csv index 70262ab7..83095c09 100644 --- a/tests/test_input_files/optimization/trips_for_optimizer.csv +++ b/tests/test_input_files/optimization/trips_for_optimizer.csv @@ -6,5 +6,7 @@ LINE_0,Station-3,2022-03-07 22:51:00,2022-03-07 23:24:00,Station-4,1000,1,AB Aussetzfahrt,Station-4,2022-03-07 23:28:00,2022-03-08 00:03:00,Station-0,500,1,AB Einsetzfahrt,Station-0,2022-03-07 21:31:00,2022-03-07 21:35:00,Station-1,100,2,AB LINE_1,Station-1,2022-03-07 21:41:00,2022-03-07 22:04:00,Station-3,1000,2,AB -LINE_1,Station-3,2022-03-07 22:51:00,2022-03-07 23:24:00,Station-4,800,2,AB -Aussetzfahrt,Station-4,2022-03-07 23:28:00,2022-03-08 00:03:00,Station-0,500,2,AB \ No newline at end of file +LINE_1,Station-3,2022-03-07 22:15:00,2022-03-07 23:24:00,Station-4,800,2,AB +LINE_1,Station-4,2022-03-07 22:28:00,2022-03-07 23:32:00,Station-3,800,2,AB +LINE_1,Station-3,2022-03-07 22:40:00,2022-03-07 23:45:00,Station-4,800,2,AB +Aussetzfahrt,Station-4,2022-03-07 23:48:00,2022-03-08 00:03:00,Station-0,500,2,AB \ No newline at end of file diff --git a/tests/test_station_optimization.py b/tests/test_station_optimization.py index 2f68067c..08652c20 100644 --- a/tests/test_station_optimization.py +++ b/tests/test_station_optimization.py @@ -11,16 +11,17 @@ from simba import station_optimizer import simba.optimizer_util as opt_util from simba.data_container import DataContainer -from simba.schedule import Schedule from simba.simulate import pre_simulation from simba.station_optimization import run_optimization import simba.util as util -from tests.helpers import initialize_consumption from spice_ev.report import generate_soc_timeseries from tests.conftest import example_root file_root = Path(__file__).parent / "test_input_files/optimization" +from matplotlib import pyplot as plt +import matplotlib +matplotlib.use('TkAgg') def slow_join_all_subsets(subsets): def join_subsets(subsets): @@ -107,30 +108,31 @@ def setup_test(self, tmp_path): dst = tmp_path / "simba.cfg" dst.write_text(src_text) - def basic_run(self, trips_file_name="trips.csv"): + def generate_datacontainer_args(self, trips_file_name="trips.csv"): """ Check if running a basic example works and if a scenario object is returned. :param trips_file_name: file name of the trips file. Has to be inside the test_input_file folder :type trips_file_name: str - :return: schedule, scenario""" + :return: schedule, scenario, args""" sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] args = util.get_args() + args.output_directory = self.tmp_path + args.results_directory = self.tmp_path + assert self.tmp_path + args.input_schedule = file_root / trips_file_name - args.preferred_charging_type = "oppb" data_container = DataContainer().fill_with_args(args) + return data_container, args + + def generate_schedule_scenario(self, args, data_container): generated_schedule, args = pre_simulation(args, data_container) - # Create soc dispatcher - generated_schedule.init_soc_dispatcher(args) - generated_schedule.assign_vehicles(args) scen = generated_schedule.run(args) + # optimization depends on vehicle_socs, therefore they need to be generated generate_soc_timeseries(scen) - args.output_directory = self.tmp_path - args.results_directory = self.tmp_path - assert self.tmp_path - return generated_schedule, scen, args + return generated_schedule, scen def test_join_all_subsets(self): subsets = [{1, 2, 3}, {3, 4, 6, 7}, {7, 8}, {20, 21}, {21, 22}, {6}] @@ -153,11 +155,15 @@ def test_join_all_subsets(self): def test_fast_calculations_and_events(self): """ Test if the base optimization finishes without raising errors""" trips_file_name = "trips_for_optimizer.csv" - sched, scen, args = self.basic_run(trips_file_name) + data_container, args = self.generate_datacontainer_args(trips_file_name) + data_container.stations_data = {} + args.preferred_charging_type = "oppb" + sched, scen = self.generate_schedule_scenario(args, data_container) t = sched.rotations["2"].trips[0] - t.distance = 10000 + t.distance = 100000 sched.calculate_consumption() sched.stations["Station-1"] = {"type": "opps", "n_charging_stations": None} + sched.stations["Station-2"] = {"type": "opps", "n_charging_stations": None} scen = sched.run(args) generate_soc_timeseries(scen) @@ -169,9 +175,13 @@ def test_fast_calculations_and_events(self): sopt.create_charging_curves() # remove none values from socs in the vehicle_socs sopt.replace_socs_from_none_to_value() - vehicle_socs_fast = sopt.timeseries_calc({"Station-1"}) + vehicle_socs_fast = sopt.timeseries_calc(list(sched.stations.keys())) for vehicle, socs in scen.vehicle_socs.items(): + plt.plot(socs) + plt.plot(vehicle_socs_fast[vehicle]) + assert vehicle_socs_fast[vehicle][-1] == pytest.approx(socs[-1], 0.01, abs=0.01) + events = sopt.get_low_soc_events(soc_data=vehicle_socs_fast, rel_soc=True) assert len(events) == 1 e = events[0] @@ -202,7 +212,10 @@ def test_fast_calculations_and_events(self): def test_basic_optimization(self): """ Test if the base optimization finishes without raising errors""" trips_file_name = "trips_for_optimizer.csv" - sched, scen, args = self.basic_run(trips_file_name) + data_container, args = self.generate_datacontainer_args(trips_file_name) + data_container.stations_data = {} + args.preferred_charging_type = "oppb" + sched, scen = self.generate_schedule_scenario(args, data_container) config_path = example_root / "default_optimizer.cfg" conf = opt_util.read_config(config_path) @@ -213,7 +226,9 @@ def test_basic_optimization(self): def test_schedule_consistency(self): """ Test if the optimization returns all rotations even when some filters are active""" trips_file_name = "trips_for_optimizer.csv" - sched, scen, args = self.basic_run(trips_file_name) + data_container, args = self.generate_datacontainer_args(trips_file_name) + args.preferred_charging_type = "oppb" + sched, scen = self.generate_schedule_scenario(args, data_container) config_path = example_root / "default_optimizer.cfg" conf = opt_util.read_config(config_path) @@ -261,11 +276,17 @@ def test_deep_optimization(self): """ trips_file_name = "trips_for_optimizer_deep.csv" - sched, scen, args = self.basic_run(trips_file_name=trips_file_name) - args.input_schedule = file_root / trips_file_name + data_container, args = self.generate_datacontainer_args(trips_file_name) + data_container.stations_data = {} + args.preferred_charging_type = "oppb" + for trip_d in data_container.trip_data: + trip_d["distance"] *= 15 + sched, scen = self.generate_schedule_scenario(args, data_container) config_path = example_root / "default_optimizer.cfg" conf = opt_util.read_config(config_path) + assert len(sched.get_negative_rotations(scen)) == 2 + solvers = ["quick", "spiceev"] node_choices = ["step-by-step", "brute"] conf.opt_type = "deep" @@ -279,15 +300,21 @@ def test_deep_optimization(self): assert "Station-2" in opt_sched.stations assert "Station-3" in opt_sched.stations + def test_deep_optimization_extended(self): trips_file_name = "trips_extended.csv" - # adjust mileage so scenario is not possible without adding electrification - - self.vehicle_types = adjust_vehicle_file(args.vehicle_types_path, mileage=2, capacity=150) - sched, scen, args = self.basic_run(trips_file_name=trips_file_name) + data_container, args = self.generate_datacontainer_args(trips_file_name) + data_container.stations_data = {} + args.preferred_charging_type = "oppb" + sched, scen = self.generate_schedule_scenario(args, data_container) # optimization can only be properly tested if negative rotations exist assert len(sched.get_negative_rotations(scen)) > 0 args.input_schedule = file_root / trips_file_name + config_path = example_root / "default_optimizer.cfg" + conf = opt_util.read_config(config_path) + solvers = ["quick", "spiceev"] + node_choices = ["step-by-step", "brute"] + conf.opt_type = "deep" opt_stat = None for solver in solvers: for node_choice in node_choices: @@ -309,7 +336,12 @@ def test_critical_stations_optimization(self, caplog): to have access to logging data """ trips_file_name = "trips_for_optimizer_deep.csv" - sched, scen, args = self.basic_run(trips_file_name=trips_file_name) + data_container, args = self.generate_datacontainer_args(trips_file_name) + for trip_d in data_container.trip_data: + trip_d["distance"] *= 15 + data_container.stations_data = {} + args.preferred_charging_type = "oppb" + sched, scen = self.generate_schedule_scenario(args, data_container) args.input_schedule = file_root / trips_file_name config_path = example_root / "default_optimizer.cfg" conf = opt_util.read_config(config_path) @@ -336,3 +368,4 @@ def adjust_vehicle_file(source, capacity=None, mileage=0): with open(source, "w", encoding='utf-8') as file: json.dump(vehicle_types, file) return vehicle_types + From f6879fafca98c0be4993f9aa4170089553e9de09 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Mon, 3 Jun 2024 10:06:43 +0200 Subject: [PATCH 28/68] Fix recombination --- tests/helpers.py | 18 ++---------------- tests/test_optimization.py | 7 ++++++- tests/test_schedule.py | 2 +- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 694afcff..d85a0a4c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,17 +1,13 @@ """ Reusable functions that support tests """ import sys -from copy import deepcopy - -from simba import trip, consumption, util +from simba import util from simba.data_container import DataContainer from simba.simulate import pre_simulation from tests.conftest import example_root -def generate_basic_schedule(cache=[None]): - if cache[0] is not None: - return deepcopy(cache[0]) +def generate_basic_schedule(): sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] args = util.get_args() args.preferred_charging_type = "oppb" @@ -28,15 +24,5 @@ def generate_basic_schedule(cache=[None]): vars(args).update(mandatory_args) data_container = DataContainer().fill_with_args(args) generated_schedule, args = pre_simulation(args, data_container) - cache[0] = deepcopy((generated_schedule, args)) return generated_schedule, args - -def initialize_consumption(vehicle_types): - data_container = DataContainer() - data_container.add_vehicle_types(vehicle_types) - data_container.add_consumption_data_from_vehicle_type_linked_files() - trip.Trip.consumption = consumption.Consumption(vehicle_types) - for name, df in data_container.consumption_data.items(): - trip.Trip.consumption.set_consumption_interpolation(name, df) - return trip.Trip.consumption diff --git a/tests/test_optimization.py b/tests/test_optimization.py index 32836beb..9b69c6b5 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -109,10 +109,15 @@ def test_recombination(self): new_rot_name = f"{rot_id}_r_{counter}" assert len(trip_list) == trip_counter - # make one trip impossible + # make all trips except one easily possible schedule = original_schedule trips = deepcopy(original_trips) + for trips_ in trips.values(): + for t in trips_: + t.delta_soc = 0.0000001 + # make one trip impossible trips["1"][0].delta_soc = -2 + recombined_schedule = optimization.recombination(schedule, args, trips, depot_trips) # add Ein/Aussetzfahrt, subtract one impossible trip assert len(original_trips["1"]) + 2 - 1 == len(recombined_schedule.rotations["1_r"].trips) diff --git a/tests/test_schedule.py b/tests/test_schedule.py index af8b5b91..a3aa3e89 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -7,7 +7,7 @@ from simba.simulate import pre_simulation from tests.conftest import example_root, file_root -from tests.helpers import generate_basic_schedule, initialize_consumption +from tests.helpers import generate_basic_schedule from simba import rotation, schedule, util from simba.data_container import DataContainer From bbc1f98333f56d2d141d03a637fe44b57bd537ab Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Mon, 3 Jun 2024 10:07:22 +0200 Subject: [PATCH 29/68] Change trips for optimizer --- tests/test_input_files/optimization/trips_for_optimizer.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_input_files/optimization/trips_for_optimizer.csv b/tests/test_input_files/optimization/trips_for_optimizer.csv index 83095c09..3dd6ce35 100644 --- a/tests/test_input_files/optimization/trips_for_optimizer.csv +++ b/tests/test_input_files/optimization/trips_for_optimizer.csv @@ -4,7 +4,7 @@ LINE_0,Station-1,2022-03-07 21:41:00,2022-03-07 22:04:00,Station-2,1000,1,AB LINE_0,Station-2,2022-03-07 22:08:00,2022-03-07 22:43:00,Station-3,2000,1,AB LINE_0,Station-3,2022-03-07 22:51:00,2022-03-07 23:24:00,Station-4,1000,1,AB Aussetzfahrt,Station-4,2022-03-07 23:28:00,2022-03-08 00:03:00,Station-0,500,1,AB -Einsetzfahrt,Station-0,2022-03-07 21:31:00,2022-03-07 21:35:00,Station-1,100,2,AB +Einsetzfahrt,Station-0,2022-03-07 21:25:00,2022-03-07 21:35:00,Station-1,100,2,AB LINE_1,Station-1,2022-03-07 21:41:00,2022-03-07 22:04:00,Station-3,1000,2,AB LINE_1,Station-3,2022-03-07 22:15:00,2022-03-07 23:24:00,Station-4,800,2,AB LINE_1,Station-4,2022-03-07 22:28:00,2022-03-07 23:32:00,Station-3,800,2,AB From 3a65ffabaa9b96b66d3b910583143c7929776d7c Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Mon, 3 Jun 2024 10:23:08 +0200 Subject: [PATCH 30/68] Fix assign tests --- tests/test_schedule.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_schedule.py b/tests/test_schedule.py index a3aa3e89..c2b3b0f2 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -104,13 +104,14 @@ def test_assign_vehicles_fixed_recharge(self): sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] args = util.get_args() + args.min_recharge_deps_oppb = 1 + args.min_recharge_deps_depb = 1 args.input_schedule = file_root / "trips_assign_vehicles_extended.csv" data_container = DataContainer().fill_with_args(args) generated_schedule, args = pre_simulation(args, data_container) all_rotations = [r for r in generated_schedule.rotations] - args = Namespace(**{}) args.assign_strategy = "fixed_recharge" generated_schedule.assign_vehicles(args) gen_rotations = generated_schedule.rotations @@ -122,11 +123,11 @@ def test_assign_vehicles_fixed_recharge(self): if "_2" in r or "_3" in r: del generated_schedule.rotations[r] - args = Namespace(**{}) args.assign_strategy = "fixed_recharge" generated_schedule.assign_vehicles(args) gen_rotations = generated_schedule.rotations vehicle_ids = {rot.vehicle_id for key, rot in gen_rotations.items()} + assert len(vehicle_ids) == 4 # Assertions based on the following output / graphic From 3a79e1927f399bef4231805932f81f3cccc8e23e Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Mon, 3 Jun 2024 10:35:00 +0200 Subject: [PATCH 31/68] Make flake8 happy --- simba/__init__.py | 3 --- simba/consumption.py | 9 ++------- simba/data_container.py | 10 +++++----- simba/schedule.py | 6 ++++-- simba/trip.py | 2 +- tests/helpers.py | 1 - tests/test_consumption.py | 4 +--- tests/test_nd_interpolate.py | 1 - tests/test_rotation.py | 3 --- tests/test_schedule.py | 2 +- tests/test_soc_dispatcher.py | 1 - tests/test_station_optimization.py | 7 ------- 12 files changed, 14 insertions(+), 35 deletions(-) diff --git a/simba/__init__.py b/simba/__init__.py index 99a4cccd..e69de29b 100644 --- a/simba/__init__.py +++ b/simba/__init__.py @@ -1,3 +0,0 @@ -from simba.schedule import Schedule -from simba.rotation import Rotation -from simba.trip import Trip \ No newline at end of file diff --git a/simba/consumption.py b/simba/consumption.py index 3fade0e4..6928b64f 100644 --- a/simba/consumption.py +++ b/simba/consumption.py @@ -1,4 +1,3 @@ -import csv import warnings import pandas as pd @@ -12,11 +11,9 @@ def __init__(self) -> None: self.consumption_interpolation = {} def __call__(self, distance, vehicle_type, vehicle_info, temp, - height_difference, level_of_loading, mean_speed): + height_difference, level_of_loading, mean_speed): """ Calculates consumed amount of energy for a given distance. - :param time: The date and time at which the trip ends - :type time: datetime.datetime :param distance: Distance travelled [m] :type distance: float :param vehicle_type: The vehicle type for which to calculate consumption @@ -132,10 +129,8 @@ def interpol_function(this_incline, this_temp, this_lol, this_speed): return interpol_function - def get_consumption_lookup_name(self, consumption_path, vehicle_type): - """ - Get name for the consumption lookup. + """ Get name for the consumption lookup. :param consumption_path: Path to consumption data. :type consumption_path: str diff --git a/simba/data_container.py b/simba/data_container.py index f857b0b3..6e966785 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -124,13 +124,13 @@ def add_station_geo_data_from_csv(self, file_path: Path) -> 'DataContainer': } except (FileNotFoundError, KeyError): logging.warning("External csv file '{}' not found or not named properly " - "(Needed column names are 'Endhaltestelle' and 'elevation')". - format(file_path), - stacklevel=100) + "(Needed column names are 'Endhaltestelle' and 'elevation')". + format(file_path), + stacklevel=100) except ValueError: logging.warning("External csv file '{}' should only contain numeric data". - format(file_path), - stacklevel=100) + format(file_path), + stacklevel=100) return self def add_level_of_loading_data(self, data: dict) -> 'DataContainer': diff --git a/simba/schedule.py b/simba/schedule.py index 24d311b9..021bafa9 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -487,7 +487,8 @@ def assign_vehicles_w_adaptive_soc(self, args): standing_vehicles_w_soc = [(*v, rot_idx) for v, rot_idx in zip(standing_vehicles, socs)] standing_vehicles_w_soc = sorted(standing_vehicles_w_soc, key=lambda x: x[-1]) - consumption_soc = self.calculate_rotation_consumption(rot) / self.vehicle_types[vt][ct]["capacity"] + consumption_soc = (self.calculate_rotation_consumption(rot) / + self.vehicle_types[vt][ct]["capacity"]) for vehicle_id, depot, soc in standing_vehicles_w_soc: # end soc of vehicle if it services this rotation end_soc = soc - consumption_soc @@ -586,7 +587,8 @@ def calculate_rotation_consumption(self, rotation: Rotation): def calculate_trip_consumption(self, trip: Trip): """ Compute consumption for this trip. - + :param trip: trip to calculate consumption for + :type trip: Trip :return: Consumption of trip [kWh] :rtype: float :raises with_traceback: if consumption cannot be constructed diff --git a/simba/trip.py b/simba/trip.py index 89bd3ebb..80ef3dd4 100644 --- a/simba/trip.py +++ b/simba/trip.py @@ -1,5 +1,6 @@ from datetime import timedelta + class Trip: def __init__(self, rotation, departure_time, departure_name, arrival_time, arrival_name, distance, temperature, level_of_loading, @@ -30,4 +31,3 @@ def __init__(self, rotation, departure_time, departure_name, self.consumption = None # kWh self.delta_soc = None - diff --git a/tests/helpers.py b/tests/helpers.py index d85a0a4c..cf44ccfb 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -25,4 +25,3 @@ def generate_basic_schedule(): data_container = DataContainer().fill_with_args(args) generated_schedule, args = pre_simulation(args, data_container) return generated_schedule, args - diff --git a/tests/test_consumption.py b/tests/test_consumption.py index a8b9fa0f..bf114faa 100644 --- a/tests/test_consumption.py +++ b/tests/test_consumption.py @@ -1,7 +1,5 @@ -import pytest from tests.test_schedule import BasicSchedule from tests.conftest import example_root -from datetime import datetime import pandas as pd @@ -49,7 +47,7 @@ def true_cons(lol, incline, speed, t_amb): # save the file in a temp folder and use from now on vehicle[1][charging_type]["mileage"] = "new_consumption" consumption_calculator.set_consumption_interpolation(vehicle[1][charging_type]["mileage"], - consumption_df) + consumption_df) lol = 0.5 incline = 0 diff --git a/tests/test_nd_interpolate.py b/tests/test_nd_interpolate.py index 63e259e1..561a20ce 100644 --- a/tests/test_nd_interpolate.py +++ b/tests/test_nd_interpolate.py @@ -57,7 +57,6 @@ def get_outer_point(table, dims_out_of_bound=1): return point - class TestNdInterpol: random.seed(5) linear_function = None diff --git a/tests/test_rotation.py b/tests/test_rotation.py index 76236d96..dcf19a08 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -1,5 +1,3 @@ -from copy import deepcopy - import pytest from tests.helpers import generate_basic_schedule @@ -17,7 +15,6 @@ def test_set_charging_type(): else: vehicle["mileage"] = 20 - # set charging type to oppb rot.set_charging_type('oppb') assert rot.charging_type == 'oppb' diff --git a/tests/test_schedule.py b/tests/test_schedule.py index c2b3b0f2..4fc6f006 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -74,7 +74,7 @@ def test_station_data_reading(self, caplog): """ Test if the reading of the geo station data works and outputs warnings in case the data was problematic, e.g. not numeric or not existent - :param default_schedule_arguments: basic arguments the schedule needs for creation + :param caplog: pytest fixture to capture logging """ sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] args = util.get_args() diff --git a/tests/test_soc_dispatcher.py b/tests/test_soc_dispatcher.py index c32b38e1..e74a819d 100644 --- a/tests/test_soc_dispatcher.py +++ b/tests/test_soc_dispatcher.py @@ -5,7 +5,6 @@ import pytest from simba.simulate import pre_simulation -from simba.trip import Trip from tests.conftest import example_root from simba import util from simba.data_container import DataContainer diff --git a/tests/test_station_optimization.py b/tests/test_station_optimization.py index 08652c20..3fa822db 100644 --- a/tests/test_station_optimization.py +++ b/tests/test_station_optimization.py @@ -19,9 +19,6 @@ file_root = Path(__file__).parent / "test_input_files/optimization" -from matplotlib import pyplot as plt -import matplotlib -matplotlib.use('TkAgg') def slow_join_all_subsets(subsets): def join_subsets(subsets): @@ -177,9 +174,6 @@ def test_fast_calculations_and_events(self): sopt.replace_socs_from_none_to_value() vehicle_socs_fast = sopt.timeseries_calc(list(sched.stations.keys())) for vehicle, socs in scen.vehicle_socs.items(): - plt.plot(socs) - plt.plot(vehicle_socs_fast[vehicle]) - assert vehicle_socs_fast[vehicle][-1] == pytest.approx(socs[-1], 0.01, abs=0.01) events = sopt.get_low_soc_events(soc_data=vehicle_socs_fast, rel_soc=True) @@ -368,4 +362,3 @@ def adjust_vehicle_file(source, capacity=None, mileage=0): with open(source, "w", encoding='utf-8') as file: json.dump(vehicle_types, file) return vehicle_types - From d5dbbcd3d9a3d35bdeda518759617c656630b490 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Mon, 3 Jun 2024 10:46:46 +0200 Subject: [PATCH 32/68] Fix logging --- simba/data_container.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/simba/data_container.py b/simba/data_container.py index 6e966785..b2481eb0 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -123,14 +123,12 @@ def add_station_geo_data_from_csv(self, file_path: Path) -> 'DataContainer': "long": float(row.get('long', 0)), } except (FileNotFoundError, KeyError): - logging.warning("External csv file '{}' not found or not named properly " - "(Needed column names are 'Endhaltestelle' and 'elevation')". - format(file_path), - stacklevel=100) + logging.log(msg=f"External csv file {file_path} not found or not named properly. " + "(Needed column names are 'Endhaltestelle' and 'elevation')", + level=100) except ValueError: - logging.warning("External csv file '{}' should only contain numeric data". - format(file_path), - stacklevel=100) + logging.log(msg=f"External csv file {file_path} should only contain numeric data.", + level=100) return self def add_level_of_loading_data(self, data: dict) -> 'DataContainer': From e4c8a06bd98234cc0d8f4b7ac498be430b86c19d Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Mon, 3 Jun 2024 10:59:02 +0200 Subject: [PATCH 33/68] Reduce trips for testing by filtering trips in basic_run --- tests/test_schedule.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 4fc6f006..48e88289 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -49,6 +49,9 @@ def basic_run(self): args = util.get_args() data_container = DataContainer().fill_with_args(args) + first_trip = min([t["arrival_time"] for t in data_container.trip_data]) + data_container.trip_data = [t for t in data_container.trip_data + if t["arrival_time"] - first_trip < timedelta(hours=10)] sched, args = pre_simulation(args, data_container) scen = sched.run(args) return sched, scen, args @@ -247,11 +250,11 @@ def test_get_negative_rotations(self): for rot in sched.rotations.values(): for t in rot.trips: t.distance = 0.01 - sched.rotations["11"].trips[-1].distance = 99_999 + sched.rotations["1"].trips[-1].distance = 99_999 sched.calculate_consumption() scen = sched.run(args) neg_rots = sched.get_negative_rotations(scen) - assert ['11'] == neg_rots + assert ['1'] == neg_rots def test_rotation_filter(self, tmp_path): sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] From bc7d8bbacbc81c7c12b4c0dbb0718c77d7dcdb0c Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Mon, 3 Jun 2024 11:10:16 +0200 Subject: [PATCH 34/68] Remove unneccessary return from test --- tests/test_soc_dispatcher.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_soc_dispatcher.py b/tests/test_soc_dispatcher.py index e74a819d..82bef84b 100644 --- a/tests/test_soc_dispatcher.py +++ b/tests/test_soc_dispatcher.py @@ -106,8 +106,6 @@ def test_basic_dispatching(self, eflips_output): pd.DataFrame(scen.vehicle_socs).plot() - return sched, scen, args - def test_basic_missing_rotation(self, eflips_output): """Test if missing a rotation throws an error :param eflips_output: list of eflips data From 422a91f6392fa945a0d97ab73186ce707b30aee4 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Mon, 3 Jun 2024 11:14:08 +0200 Subject: [PATCH 35/68] Fix docstring --- tests/test_soc_dispatcher.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_soc_dispatcher.py b/tests/test_soc_dispatcher.py index 82bef84b..6980f4e7 100644 --- a/tests/test_soc_dispatcher.py +++ b/tests/test_soc_dispatcher.py @@ -96,7 +96,6 @@ def eflips_output(self): def test_basic_dispatching(self, eflips_output): """Returns a schedule, scenario and args after running SimBA. :param eflips_output: list of eflips data - :return: schedule, scenario, args """ sched, scen, args = self.basic_run() pd.DataFrame(scen.vehicle_socs).plot() From abe3bec9fa550c2583443f427f142a50340a991e Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Mon, 3 Jun 2024 11:30:58 +0200 Subject: [PATCH 36/68] Fix docstring --- simba/data_container.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/simba/data_container.py b/simba/data_container.py index b2481eb0..1108abd2 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -81,6 +81,17 @@ def fill_with_paths(self, cost_parameters_path=None, station_data_path=None, ): + """ Fill self with data from file_paths + :param trips_file_path: csv path to trips + :param vehicle_types_path: json file path to vehicle_types + :param electrified_stations_path: json file path to electrified stations + :param outside_temperature_over_day_path: csv path to temperatures over the hour of day + :param level_of_loading_over_day_path: csv path to level of loading over the hour of day + :param cost_parameters_path: json file path to cost_parameters + :param station_data_path: csv file path to station geo_data + :return: self + """ + # Add the vehicle_types from a json file self.add_vehicle_types_from_json(vehicle_types_path) @@ -161,6 +172,11 @@ def add_temperature_data(self, data: dict) -> 'DataContainer': return self def add_temperature_data_from_csv(self, file_path: Path) -> 'DataContainer': + """ Get temperature data from a path and raise verbose error if file is not found. + + :param file_path: csv path to temperature over hour of day + :return: DataContainer containing cost parameters + """ index = "hour" column = "temperature" temperature_data_dict = get_dict_from_csv(column, file_path, index) @@ -248,6 +264,10 @@ def get_json_from_file(file_path: Path, data_type: str) -> any: "does not exist. Exiting...") def add_consumption_data_from_vehicle_type_linked_files(self): + """ Add mileage data from files linked in the vehicle_types to the container. + + :return: DataContainer containing consumption data + """ assert self.vehicle_types_data, "No vehicle_type data in the data_container" mileages = list(get_values_from_nested_key("mileage", self.vehicle_types_data)) mileages = list(filter(lambda x: isinstance(x, str) or isinstance(x, Path), mileages, )) @@ -298,6 +318,16 @@ def get_values_from_nested_key(key, data: dict) -> list: def get_dict_from_csv(column, file_path, index): + """ Get a dictonary with the key of a numeric index and the value of a numeric column + + :param column: column name for dictionary values. Content needs to be castable to float + :type column: str + :param file_path: file path + :type file_path: str or Path + :param index: column name of the index / keys of the dictionary. + Content needs to be castable to float + :return: dictionary with numeric keys of index and numeric values of column + """ output = dict() with open(file_path, "r") as f: delim = util.get_csv_delim(file_path) @@ -308,6 +338,13 @@ def get_dict_from_csv(column, file_path, index): def cast_float_or_none(val: any) -> any: + """ Cast a value to float. If a ValueError or TypeError is raised, None is returned + + :param val: value to cast + :type val: any + :return: casted value + """ + try: return float(val) except (ValueError, TypeError): From d033015fd6665e9a218033cc155d43d21f00dd43 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Fri, 7 Jun 2024 14:18:22 +0200 Subject: [PATCH 37/68] Expose cost calculation strategy through args --- simba/costs.py | 12 ++++++++++-- simba/simulate.py | 2 +- simba/util.py | 7 +++++++ tests/test_cost_calculation.py | 0 4 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 tests/test_cost_calculation.py diff --git a/simba/costs.py b/simba/costs.py index 3e96e0c8..e94e3a51 100644 --- a/simba/costs.py +++ b/simba/costs.py @@ -53,7 +53,7 @@ def calculate_costs(c_params, scenario, schedule, args): logging.info(cost_object.info()) - setattr(scenario, "costs", cost_object) + return cost_object class Costs: @@ -303,10 +303,18 @@ def set_electricity_costs(self): if pv.parent == gcID]) timeseries = vars(self.scenario).get(f"{gcID}_timeseries") + + # Get the calculation strategy / method from args. + # If no value is set use the same strategy as the charging strategy + default_cost_strategy = vars(self.args)["strategy_" + station.get("type")] + + strategy_name = "cost_calculation_strategy" + station.get("type") + cost_calculation_strategy = vars(self.args).get(strategy_name, default_cost_strategy) + # calculate costs for electricity try: costs_electricity = calc_costs_spice_ev( - strategy=vars(self.args)["strategy_" + station.get("type")], + strategy=cost_calculation_strategy, voltage_level=gc.voltage_level, interval=self.scenario.interval, timestamps_list=timeseries.get("time"), diff --git a/simba/simulate.py b/simba/simulate.py index 67092284..2a4f1f53 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -234,7 +234,7 @@ def report(schedule, scenario, args, i): if args.cost_calculation: # cost calculation part of report try: - calculate_costs(args.cost_parameters, scenario, schedule, args) + scenario.costs = calculate_costs(args.cost_parameters, scenario, schedule, args) except Exception: logging.warning(f"Cost calculation failed due to {traceback.print_exc()}") if args.propagate_mode_errors: diff --git a/simba/util.py b/simba/util.py index cc53ec79..cc3abb70 100644 --- a/simba/util.py +++ b/simba/util.py @@ -317,6 +317,13 @@ def get_args(): help='strategy to use in depot') parser.add_argument('--strategy-opps', default='greedy', choices=STRATEGIES, help='strategy to use at station') + + # #### Cost calculation strategy ##### + parser.add_argument('--cost-calculation-strategy-deps', choices=STRATEGIES, + help='Strategy for cost calculation to use in depot') + parser.add_argument('--cost-calculation-strategy-opps', choices=STRATEGIES, + help='Strategy for cost calculation to use at station') + parser.add_argument('--strategy-options-deps', default={}, type=lambda s: s if type(s) is dict else json.loads(s), help='special strategy options to use in depot') diff --git a/tests/test_cost_calculation.py b/tests/test_cost_calculation.py new file mode 100644 index 00000000..e69de29b From b9b38c251e4cfaa9d4650a9903247c2086618d19 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Fri, 7 Jun 2024 14:18:32 +0200 Subject: [PATCH 38/68] Start adding tests --- tests/test_cost_calculation.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_cost_calculation.py b/tests/test_cost_calculation.py index e69de29b..92d41a2a 100644 --- a/tests/test_cost_calculation.py +++ b/tests/test_cost_calculation.py @@ -0,0 +1,17 @@ +from tests.test_schedule import BasicSchedule +from simba.util import uncomment_json_file +from simba.costs import calculate_costs + +class TestCostCalculation: + def test_cost_calculation(self): + schedule, scenario, args = BasicSchedule().basic_run() + file = args.cost_parameters_file + with open(file, "r") as file: + cost_params = uncomment_json_file(file) + + costs = calculate_costs(cost_params, scenario, schedule, args) + + assert args.strategy_deps == "balanced" + assert args.strategy_opps == "greedy" + + args.cost_calculation_strategy_opps == "balanced" From 2ee22f5b9b36c6855b82d3429994a9663c79a52f Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Fri, 7 Jun 2024 14:53:05 +0200 Subject: [PATCH 39/68] Add missing underline --- simba/costs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simba/costs.py b/simba/costs.py index e94e3a51..100644d4 100644 --- a/simba/costs.py +++ b/simba/costs.py @@ -308,7 +308,7 @@ def set_electricity_costs(self): # If no value is set use the same strategy as the charging strategy default_cost_strategy = vars(self.args)["strategy_" + station.get("type")] - strategy_name = "cost_calculation_strategy" + station.get("type") + strategy_name = "cost_calculation_strategy_" + station.get("type") cost_calculation_strategy = vars(self.args).get(strategy_name, default_cost_strategy) # calculate costs for electricity From 8bd4a949ab3fcae7ab6746ba763e9c7a72a148b2 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Fri, 7 Jun 2024 15:09:48 +0200 Subject: [PATCH 40/68] Add tests and fix default value for cost_calc_strategy --- simba/costs.py | 4 +-- tests/test_cost_calculation.py | 50 ++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/simba/costs.py b/simba/costs.py index 100644d4..e03bd2b1 100644 --- a/simba/costs.py +++ b/simba/costs.py @@ -305,11 +305,11 @@ def set_electricity_costs(self): # Get the calculation strategy / method from args. - # If no value is set use the same strategy as the charging strategy + # If no value is set, use the same strategy as the charging strategy default_cost_strategy = vars(self.args)["strategy_" + station.get("type")] strategy_name = "cost_calculation_strategy_" + station.get("type") - cost_calculation_strategy = vars(self.args).get(strategy_name, default_cost_strategy) + cost_calculation_strategy = vars(self.args).get(strategy_name) or default_cost_strategy # calculate costs for electricity try: diff --git a/tests/test_cost_calculation.py b/tests/test_cost_calculation.py index 92d41a2a..b5ce6b40 100644 --- a/tests/test_cost_calculation.py +++ b/tests/test_cost_calculation.py @@ -2,6 +2,7 @@ from simba.util import uncomment_json_file from simba.costs import calculate_costs + class TestCostCalculation: def test_cost_calculation(self): schedule, scenario, args = BasicSchedule().basic_run() @@ -9,9 +10,54 @@ def test_cost_calculation(self): with open(file, "r") as file: cost_params = uncomment_json_file(file) - costs = calculate_costs(cost_params, scenario, schedule, args) + assert args.strategy_deps == "balanced" + assert args.strategy_opps == "greedy" + + args.cost_calculation_strategy_deps = None + args.cost_calculation_strategy_opps = None + + costs_vanilla = calculate_costs(cost_params, scenario, schedule, args) assert args.strategy_deps == "balanced" assert args.strategy_opps == "greedy" - args.cost_calculation_strategy_opps == "balanced" + args.cost_calculation_strategy_deps = "balanced" + args.cost_calculation_strategy_opps = "greedy" + costs_with_same_strat = calculate_costs(cost_params, scenario, schedule, args) + + # assert all costs are the same + for station in costs_vanilla.costs_per_gc: + for key in costs_vanilla.costs_per_gc[station]: + assert (costs_vanilla.costs_per_gc[station][key] == + costs_with_same_strat.costs_per_gc[station][key]), station + + args.cost_calculation_strategy_opps = "balanced_market" + args.cost_calculation_strategy_deps = "balanced_market" + costs_with_other_strat = calculate_costs(cost_params, scenario, schedule, args) + print(costs_vanilla.costs_per_gc["cumulated"]["c_total_annual"]) + print(costs_with_other_strat.costs_per_gc["cumulated"]["c_total_annual"]) + station = "cumulated" + for key in costs_vanilla.costs_per_gc[station]: + if not "el_energy" in key: + continue + assert (costs_vanilla.costs_per_gc[station][key] != + costs_with_other_strat.costs_per_gc[station][key]), key + + args.cost_calculation_strategy_opps = "peak_load_window" + args.cost_calculation_strategy_deps = "peak_load_window" + costs_with_other_strat = calculate_costs(cost_params, scenario, schedule, args) + station = "cumulated" + for key in costs_vanilla.costs_per_gc[station]: + if not "el_energy" in key: + continue + assert (costs_vanilla.costs_per_gc[station][key] != + costs_with_other_strat.costs_per_gc[station][key]), key + + args.cost_calculation_strategy_opps = "peak_shaving" + args.cost_calculation_strategy_deps = "peak_shaving" + costs_with_other_strat = calculate_costs(cost_params, scenario, schedule, args) + # assert all costs are the same + for station in costs_vanilla.costs_per_gc: + for key in costs_vanilla.costs_per_gc[station]: + assert (costs_vanilla.costs_per_gc[station][key] == + costs_with_other_strat.costs_per_gc[station][key]), station From 2678ba71d2a1221f6b4836317d0f75b31f259324 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Fri, 7 Jun 2024 15:13:44 +0200 Subject: [PATCH 41/68] Make flake8 happy --- simba/costs.py | 2 +- tests/test_cost_calculation.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/simba/costs.py b/simba/costs.py index e03bd2b1..7523869d 100644 --- a/simba/costs.py +++ b/simba/costs.py @@ -19,6 +19,7 @@ def calculate_costs(c_params, scenario, schedule, args): :type schedule: Schedule :param args: Configuration arguments specified in config files contained in configs directory :type args: argparse.Namespace + :return: cost object """ cost_object = Costs(schedule, scenario, args, c_params) @@ -303,7 +304,6 @@ def set_electricity_costs(self): if pv.parent == gcID]) timeseries = vars(self.scenario).get(f"{gcID}_timeseries") - # Get the calculation strategy / method from args. # If no value is set, use the same strategy as the charging strategy default_cost_strategy = vars(self.args)["strategy_" + station.get("type")] diff --git a/tests/test_cost_calculation.py b/tests/test_cost_calculation.py index b5ce6b40..39fb36d1 100644 --- a/tests/test_cost_calculation.py +++ b/tests/test_cost_calculation.py @@ -38,7 +38,7 @@ def test_cost_calculation(self): print(costs_with_other_strat.costs_per_gc["cumulated"]["c_total_annual"]) station = "cumulated" for key in costs_vanilla.costs_per_gc[station]: - if not "el_energy" in key: + if "el_energy" not in key: continue assert (costs_vanilla.costs_per_gc[station][key] != costs_with_other_strat.costs_per_gc[station][key]), key @@ -48,7 +48,7 @@ def test_cost_calculation(self): costs_with_other_strat = calculate_costs(cost_params, scenario, schedule, args) station = "cumulated" for key in costs_vanilla.costs_per_gc[station]: - if not "el_energy" in key: + if "el_energy" not in key: continue assert (costs_vanilla.costs_per_gc[station][key] != costs_with_other_strat.costs_per_gc[station][key]), key From d4d1ce3dc57fc0a603283d5cd3a71ea6db73e9c7 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 13 Jun 2024 16:06:14 +0200 Subject: [PATCH 42/68] Implement review changes. --- simba/data_container.py | 74 +++++++++++------------------ simba/ids.py | 3 ++ simba/optimizer_util.py | 2 +- simba/report.py | 3 -- simba/rotation.py | 2 +- simba/schedule.py | 91 +++++++++++++++++------------------- simba/simulate.py | 1 + simba/station_optimizer.py | 57 +++++++++++----------- simba/util.py | 36 ++++++++++++++ tests/test_schedule.py | 8 ++-- tests/test_soc_dispatcher.py | 41 ++++++++-------- 11 files changed, 164 insertions(+), 154 deletions(-) diff --git a/simba/data_container.py b/simba/data_container.py index 1108abd2..dfb0657f 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -8,20 +8,28 @@ import pandas as pd -from simba import util +from simba import util, ids from simba.ids import INCLINE, LEVEL_OF_LOADING, SPEED, T_AMB, TEMPERATURE, CONSUMPTION class DataContainer: def __init__(self): + # Dictionary of dict[VehicleTypeName][ChargingType] containing the vehicle_info dictionary self.vehicle_types_data: Dict[str, any] = {} + # Dictionary of dict[consumption_lookup_name] containing a consumption lookup self.consumption_data: Dict[str, pd.DataFrame] = {} + # Dictionary of dict[hour_of_day] containing temperature in °C self.temperature_data: Dict[int, float] = {} + # Dictionary of dict[hour_of_day] containing level of loading [-] self.level_of_loading_data: Dict[int, float] = {} + # Dictionary of dict[station_name] containing information about electrification self.stations_data: Dict[str, dict] = {} + # Dictionary containing various infos about investment costs and grid operator self.cost_parameters_data: Dict[str, dict] = {} + # Dictionary containing all stations and their geo location (lng,lat,elevation) self.station_geo_data: Dict[str, dict] = {} - + # List of trip dictionaries containing trip information like arrival time and station + # departure time and station, distance and more self.trip_data: [dict] = [] def fill_with_args(self, args: argparse.Namespace) -> 'DataContainer': @@ -57,8 +65,8 @@ def add_trip_data_from_csv(self, file_path: Path) -> 'DataContainer': trip_d = dict(trip) trip_d["arrival_time"] = datetime.datetime.fromisoformat(trip["arrival_time"]) trip_d["departure_time"] = datetime.datetime.fromisoformat(trip["departure_time"]) - trip_d[LEVEL_OF_LOADING] = cast_float_or_none(trip.get(LEVEL_OF_LOADING)) - trip_d[TEMPERATURE] = cast_float_or_none(trip.get(TEMPERATURE)) + trip_d[LEVEL_OF_LOADING] = util.cast_float_or_none(trip.get(LEVEL_OF_LOADING)) + trip_d[TEMPERATURE] = util.cast_float_or_none(trip.get(TEMPERATURE)) trip_d["distance"] = float(trip["distance"]) self.trip_data.append(trip_d) return self @@ -123,23 +131,27 @@ def add_station_geo_data_from_csv(self, file_path: Path) -> 'DataContainer': # this data is stored in the schedule and passed to the trips, which use the information # for consumption calculation. Missing station data is handled with default values. self.station_geo_data = dict() + line_num = None try: with open(file_path, "r", encoding='utf-8') as f: delim = util.get_csv_delim(file_path) reader = csv.DictReader(f, delimiter=delim) - for row in reader: + for line_num, row in enumerate(reader): self.station_geo_data[str(row['Endhaltestelle'])] = { - "elevation": float(row['elevation']), - "lat": float(row.get('lat', 0)), - "long": float(row.get('long', 0)), + ids.ELEVATION: float(row[ids.ELEVATION]), + ids.LATITUDE: float(row.get(ids.LATITUDE, 0)), + ids.LONGITUDE: float(row.get(ids.LONGITUDE, 0)), } except (FileNotFoundError, KeyError): logging.log(msg=f"External csv file {file_path} not found or not named properly. " "(Needed column names are 'Endhaltestelle' and 'elevation')", level=100) + raise except ValueError: - logging.log(msg=f"External csv file {file_path} should only contain numeric data.", + line_num += 2 + logging.log(msg=f"Can't parse numeric data in line {line_num} from file {file_path}.", level=100) + raise return self def add_level_of_loading_data(self, data: dict) -> 'DataContainer': @@ -156,7 +168,7 @@ def add_level_of_loading_data(self, data: dict) -> 'DataContainer': def add_level_of_loading_data_from_csv(self, file_path: Path) -> 'DataContainer': index = "hour" column = "level_of_loading" - level_of_loading_data_dict = get_dict_from_csv(column, file_path, index) + level_of_loading_data_dict = util.get_dict_from_csv(column, file_path, index) self.add_level_of_loading_data(level_of_loading_data_dict) return self @@ -179,7 +191,7 @@ def add_temperature_data_from_csv(self, file_path: Path) -> 'DataContainer': """ index = "hour" column = "temperature" - temperature_data_dict = get_dict_from_csv(column, file_path, index) + temperature_data_dict = util.get_dict_from_csv(column, file_path, index) self.add_temperature_data(temperature_data_dict) return self @@ -260,7 +272,7 @@ def get_json_from_file(file_path: Path, data_type: str) -> any: with open(file_path, encoding='utf-8') as f: return util.uncomment_json_file(f) except FileNotFoundError: - raise FileNotFoundError(f"Path to {data_type} ({file_path}) " + raise FileNotFoundError(f"Path to {file_path} for {data_type} " "does not exist. Exiting...") def add_consumption_data_from_vehicle_type_linked_files(self): @@ -291,9 +303,9 @@ def add_consumption_data(self, data_name, df: pd.DataFrame) -> 'DataContainer': :type df: pd.DataFrame :return: DatacContainer instance with added consumption data """ - - for expected_col in [INCLINE, T_AMB, LEVEL_OF_LOADING, SPEED, CONSUMPTION]: - assert expected_col in df.columns, f"Consumption data is missing {expected_col}" + missing_cols = [c for c in [INCLINE, T_AMB, LEVEL_OF_LOADING, SPEED, CONSUMPTION] if + c not in df.columns] + assert not missing_cols, f"Consumption data is missing {', '.join(missing_cols)}" assert data_name not in self.consumption_data, f"{data_name} already exists in data" self.consumption_data[data_name] = df @@ -317,35 +329,3 @@ def get_values_from_nested_key(key, data: dict) -> list: yield from get_values_from_nested_key(key, value) -def get_dict_from_csv(column, file_path, index): - """ Get a dictonary with the key of a numeric index and the value of a numeric column - - :param column: column name for dictionary values. Content needs to be castable to float - :type column: str - :param file_path: file path - :type file_path: str or Path - :param index: column name of the index / keys of the dictionary. - Content needs to be castable to float - :return: dictionary with numeric keys of index and numeric values of column - """ - output = dict() - with open(file_path, "r") as f: - delim = util.get_csv_delim(file_path) - reader = csv.DictReader(f, delimiter=delim) - for row in reader: - output[float(row[index])] = float(row[column]) - return output - - -def cast_float_or_none(val: any) -> any: - """ Cast a value to float. If a ValueError or TypeError is raised, None is returned - - :param val: value to cast - :type val: any - :return: casted value - """ - - try: - return float(val) - except (ValueError, TypeError): - return None diff --git a/simba/ids.py b/simba/ids.py index 38846d7c..47532b81 100644 --- a/simba/ids.py +++ b/simba/ids.py @@ -6,3 +6,6 @@ CONSUMPTION = "consumption_kwh_per_km" VEHICLE_TYPE = "vehicle_type" TEMPERATURE = "temperature" +LONGITUDE = "lng" +LATITUDE = "lat" +ELEVATION = "elevation" \ No newline at end of file diff --git a/simba/optimizer_util.py b/simba/optimizer_util.py index 1d8ac86d..e764be06 100644 --- a/simba/optimizer_util.py +++ b/simba/optimizer_util.py @@ -491,7 +491,7 @@ def get_groups_from_events(events, impossible_stations=None, could_not_be_electr could_not_be_electrified.update([event.rotation.id]) groups = list(zip(event_groups, station_subsets)) - # each event group should have events and stations. If not something went wrong. + # each event group should have events and stations. If not, something went wrong. filtered_groups = list(filter(lambda x: len(x[0]) != 0 and len(x[1]) != 0, groups)) if len(filtered_groups) != len(groups): if optimizer: diff --git a/simba/report.py b/simba/report.py index deb7d7f6..75179fce 100644 --- a/simba/report.py +++ b/simba/report.py @@ -5,11 +5,8 @@ from typing import Iterable import matplotlib.pyplot as plt -import matplotlib from spice_ev.report import aggregate_global_results, plot, generate_reports -matplotlib.use('Agg') - def open_for_csv(filepath): """ Create a file handle to write to. diff --git a/simba/rotation.py b/simba/rotation.py index 22da36d9..2e821af4 100644 --- a/simba/rotation.py +++ b/simba/rotation.py @@ -113,7 +113,7 @@ def earliest_departure_next_rot(self): def min_standing_time(self): """Minimum duration of standing time in minutes. - No consideration of depot buffer time or charging curve + No consideration of depot buffer time or charging curve. :return: Minimum duration of standing time in minutes. """ diff --git a/simba/schedule.py b/simba/schedule.py index 021bafa9..77ec396f 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -8,6 +8,7 @@ from typing import Dict, Type, Iterable import simba.rotation +import spice_ev.strategy from simba.consumption import Consumption from simba.data_container import DataContainer from simba import util, optimizer_util @@ -20,17 +21,23 @@ class SocDispatcher: - """Dispatches the right initial SoC for every vehicle id at scenario generation. + """Initializes vehicles with specific SoCs at the start of their rotations. - Used for specific vehicle initialization for example when coupling tools.""" + The first rotation of a vehicle is initialized, later rotations have their desired_soc at + departure changed. + Used for specific vehicle initialization, for example, when coupling tools.""" - def __init__(self, - default_soc_deps: float, - default_soc_opps: float, - # vehicle_socs stores the departure soc of a rotation as a dict of the previous - # trip, since this is how the SpiceEV scenario is generated. - # The first trip of a vehicle has no previous trip and therefore is None - vehicle_socs: Dict[str, Type[Dict["simba.trip.Trip", float]]] = None): + + def __init__(self, default_soc_deps, default_soc_opps, vehicle_socs=None): + """ + :param default_soc_deps: default desired SoC at departure for depot charger + :param default_soc_opps: default desired SoC at departure for opportunity charger + :param vehicle_socs: stores the desired departure SoC dict with the keys + [vehicle_id][previous_trip], since this is how the SpiceEV scenario is generated. + The first trip of a vehicle has no previous trip. In this case, the trip key is None. + :type vehicle_socs: Dict[str, Type[Dict["simba.trip.Trip", float]]] + :return: None + """ self.default_soc_deps = default_soc_deps self.default_soc_opps = default_soc_opps self.vehicle_socs = {} @@ -102,17 +109,13 @@ def from_datacontainer(cls, data: DataContainer, args): for trip in data.trip_data: rotation_id = trip['rotation_id'] - # trip gets reference to station data and calculates height diff during trip - # initialization. Could also get the height difference from here on - # get average hour of trip if level of loading or temperature has to be read from - # auxiliary tabular data # get average hour of trip and parse to string, since tabular data has strings # as keys hour = (trip["departure_time"] + (trip["arrival_time"] - trip["departure_time"]) / 2).hour - # Get height difference from station_data + # Get height difference from station_data trip["height_difference"] = schedule.get_height_difference( trip["departure_name"], trip["arrival_name"]) @@ -223,7 +226,7 @@ def run(self, args, mode="distributed"): """ # Make sure all rotations have an assigned vehicle assert all([rot.vehicle_id is not None for rot in self.rotations.values()]) - assert mode in ["distributed", "greedy"] + assert mode in spice_ev.strategy.STRATEGIES scenario = self.generate_scenario(args) logging.info("Running SpiceEV...") @@ -346,21 +349,20 @@ def assign_vehicles_w_min_recharge_soc(self): self.vehicle_type_counts = vehicle_type_counts - def assign_vehicles_for_django(self, eflips_output: Iterable[dict]): - """Assign vehicles based on eflips outputs + def assign_vehicles_custom(self, vehicle_assigns: Iterable[dict]): + """ Assign vehicles on a custom basis. - eflips couples vehicles and returns for every rotation the departure soc and vehicle id. - This is included into simba by assigning new vehicles with the respective values. I.e. in - simba every rotation gets a new vehicle. - :param eflips_output: output from eflips meant for simba. Iterable contains - rotation_id, vehicle_id and start_soc for each rotation - :type eflips_output: iterable of dataclass "simba_input" + Assign vehicles based on a datasource, containing all rotations, their vehicle_ids and + desired start socs. + :param vehicle_assigns: Iterable of dict with keys rotation_id, vehicle_id and start_soc + for each rotation + :type vehicle_assigns: Iterable[dict] :raises KeyError: If not every rotation has a vehicle assigned to it """ - eflips_rot_dict = {d["rot"]: {"v_id": d["v_id"], "soc": d["soc"]} for d in eflips_output} - unique_vids = {d["v_id"] for d in eflips_output} + eflips_rot_dict = {d["rot"]: {"v_id": d["v_id"], "soc": d["soc"]} for d in vehicle_assigns} + unique_vids = {d["v_id"] for d in vehicle_assigns} vehicle_socs = {v_id: dict() for v_id in unique_vids} - eflips_vid_dict = {v_id: sorted([d["rot"] for d in eflips_output + eflips_vid_dict = {v_id: sorted([d["rot"] for d in vehicle_assigns if d["v_id"] == v_id], key=lambda r_id: self.rotations[r_id].departure_time) for v_id in unique_vids} @@ -402,17 +404,20 @@ def init_soc_dispatcher(self, args): default_soc_opps=args.desired_soc_opps) def assign_only_new_vehicles(self): - """ Assign new vehicle IDs to rotations + """ Assign a new vehicle to every rotation. + + Iterate over all rotations and add a vehicle for each rotation. Vehicles are named on the + basis of their vehicle_type, charging type and current amount of vehicles with this + vehicle_type / charging type combination. """ - # count number of vehicles per type - # used for unique vehicle id e.g. vehicletype_chargingtype_id + # Initialize counting of all vehicle_type / charging type combination vehicle_type_counts = {f'{vehicle_type}_{charging_type}': 0 for vehicle_type, charging_types in self.vehicle_types.items() for charging_type in charging_types.keys()} rotations = sorted(self.rotations.values(), key=lambda rot: rot.departure_time) for rot in rotations: vt_ct = f"{rot.vehicle_type}_{rot.charging_type}" - # no vehicle available for dispatch, generate new one + # Generate a new vehicle vehicle_type_counts[vt_ct] += 1 rot.vehicle_id = f"{vt_ct}_{vehicle_type_counts[vt_ct]}" self.vehicle_type_counts = vehicle_type_counts @@ -587,6 +592,7 @@ def calculate_rotation_consumption(self, rotation: Rotation): def calculate_trip_consumption(self, trip: Trip): """ Compute consumption for this trip. + :param trip: trip to calculate consumption for :type trip: Trip :return: Consumption of trip [kWh] @@ -682,25 +688,24 @@ def get_common_stations(self, only_opps=True): def get_height_difference(self, departure_name, arrival_name): """ Get the height difference of two stations. - Defaults to 0 if height data is not found :param departure_name: Departure station :type departure_name: str :param arrival_name: Arrival station :type arrival_name: str - :return: Height difference + :return: Height difference. Defaults to 0 if height data is not found. :rtype: float """ if isinstance(self.station_data, dict): - station = departure_name + station_name = departure_name try: - start_height = self.station_data[station]["elevation"] - station = arrival_name - end_height = self.station_data[arrival_name]["elevation"] + start_height = self.station_data[station_name]["elevation"] + station_name = arrival_name + end_height = self.station_data[station_name]["elevation"] return end_height - start_height except KeyError: - logging.error(f"No elevation data found for {station}. Height Difference set to 0") + logging.error(f"No elevation data found for {station_name}. Height difference set to 0") else: - logging.error("No Station Data found for schedule. Height Difference set to 0") + logging.error("No station data found for schedule. Height difference set to 0") return 0 def get_negative_rotations(self, scenario): @@ -1081,16 +1086,6 @@ def generate_scenario(self, args): json.dump(self.scenario, f, indent=2) return Scenario(self.scenario, Path()) - @classmethod - def get_dict_from_csv(cls, column, file_path, index): - output = dict() - with open(file_path, "r") as f: - delim = util.get_csv_delim(file_path) - reader = csv.DictReader(f, delimiter=delim) - for row in reader: - output[float(row[index])] = float(row[column]) - return output - def update_csv_file_info(file_info, gc_name): """ diff --git a/simba/simulate.py b/simba/simulate.py index df6d70bd..265187db 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -142,6 +142,7 @@ def sim_greedy(schedule, scenario, args, _i):# Noqa @staticmethod def sim(schedule, scenario, args, _i):# Noqa + # Base simulation function for external access. scenario = schedule.run(args, mode="distributed") return schedule, scenario diff --git a/simba/station_optimizer.py b/simba/station_optimizer.py index ac540b86..c57f9613 100644 --- a/simba/station_optimizer.py +++ b/simba/station_optimizer.py @@ -65,9 +65,9 @@ def loop(self, **kwargs): self.base_stations = self.electrified_stations.copy() self.base_not_possible_stations = self.not_possible_stations.copy() - # get events where soc fell below the minimal - # soc. The events contain info about the problematic - # time span, which includes stations which could provide a soc lift + # get events where SoC fell below the minimal + # SoC. The events contain info about the problematic + # time span, which includes stations which could provide a SoC lift base_events = self.get_low_soc_events( rel_soc=False, soc_data=self.base_scenario.vehicle_socs) @@ -114,7 +114,7 @@ def loop(self, **kwargs): self.electrified_station_set = self.base_electrified_station_set.copy() if self.config.solver == "spiceev": - # running SpiceEV changes the scenario and vehicle ids. Since the low soc events + # running SpiceEV changes the scenario and vehicle ids. Since the low SoC events # point to the base vehicle ids, these have to be used in the group optimization self.scenario = deepcopy(self.base_scenario) @@ -232,7 +232,7 @@ def loop(self, **kwargs): def get_negative_rotations_all_electrified(self, rel_soc=False): """ Get the ids for the rotations which show negative SoCs when everything is electrified. - :param rel_soc: if true, the start soc is handled like it has the desired deps soc + :param rel_soc: if true, the start SoC is handled like it has the desired deps SoC :type rel_soc: bool :return: rotation ids which are negative even with all stations electrified :rtype: set @@ -342,11 +342,11 @@ def group_optimization(self, group, choose_station_function, track_not_possible_ if solver == "quick": # quick calculation has to electrify everything in one step, that is chronologically. - # or the lifting of socs is not correct. - # Since its only adding charge on top of soc time series, electrification that took + # or the lifting of SoCs is not correct. + # Since its only adding charge on top of SoC time series, electrification that took # place before in the optimization but later in the time series in regards to the # current electrification would show to much charge, since charging curves decrease over - # soc. + # SoC. self.deepcopy_socs() electrified_stations = self.electrified_station_set.union(best_station_ids) self.scenario.vehicle_socs = self.timeseries_calc(electrified_stations, event_rotations) @@ -430,7 +430,7 @@ def group_optimization(self, group, choose_station_function, track_not_possible_ @opt_util.time_it def deepcopy_socs(self): - """ Deepcopy of the socs in the scenario.""" + """ Deepcopy of the SoCs in the scenario.""" self.scenario.vehicle_socs = deepcopy(self.base_scenario.vehicle_socs) @opt_util.time_it @@ -451,11 +451,11 @@ def sort_station_events(self, charge_events_single_station): @opt_util.time_it def timeseries_calc(self, electrified_stations: set, rotations=None) -> dict: - """ A quick estimation of socs. + """ A quick estimation of SoCs. - Iterates through rotations and calculates the soc. + Iterates through rotations and calculates the SoC. The start value is assumed to be desired_soc_deps. - The function subtracts the consumption of each trip, + The function subtracts the consumption of each trip and numerically estimates the charge at the electrified station. Charging powers depend on the soc_charge_curve_dict, which were earlier created using the vehicle charge curve and the schedule.cs_power_opps. @@ -466,7 +466,7 @@ def timeseries_calc(self, electrified_stations: set, rotations=None) -> dict: :type rotations: iterable[Rotation] :param electrified_stations: Stations which are electrified. Default None leads to using the so far optimized optimizer.electrified_station_set - :return: Returns soc dict with lifted socs + :return: Returns SoC dict with lifted SoCs :rtype dict() """ if rotations is None: @@ -490,8 +490,7 @@ def timeseries_calc(self, electrified_stations: set, rotations=None) -> dict: d_soc = trip.consumption / capacity # Linear interpolation of SoC during trip - soc[idx_start:idx_end + 1] = np.linspace(last_soc, last_soc - d_soc, - delta_idx) + soc[idx_start:idx_end + 1] = np.linspace(last_soc, last_soc - d_soc, delta_idx) # Update last known SoC with current value last_soc = last_soc - d_soc @@ -499,8 +498,8 @@ def timeseries_calc(self, electrified_stations: set, rotations=None) -> dict: # Fill the values while the vehicle is standing waiting for the next trip idx = opt_util.get_index_by_time(self.scenario, trip.arrival_time) try: - idx_end = opt_util.get_index_by_time(self.scenario, - rot.trips[i + 1].departure_time) + idx_end = opt_util.get_index_by_time( + self.scenario, rot.trips[i + 1].departure_time) except IndexError: # No next trip. Rotation finished. break @@ -518,12 +517,12 @@ def timeseries_calc(self, electrified_stations: set, rotations=None) -> dict: standing_time_min = opt_util.get_charging_time( trip, rot.trips[i + 1], self.args) - # Assume that the start soc for charging is at least 0, since this results in the + # Assume that the start SoC for charging is at least 0, since this results in the # largest possible charge for a monotonous decreasing charge curve in the SoC range # of [0%,100%]. search_soc = max(0, soc[idx]) - # Get the soc lift + # Get the SoC lift d_soc = opt_util.get_delta_soc(soc_over_time_curve, search_soc, standing_time_min) # Clip the SoC lift to the desired_soc_deps, @@ -1039,7 +1038,7 @@ def get_low_soc_events(self, rotations: Iterable = None, filter_standing_time=Tr # If using relative SOC, SOC lookup has to be adjusted if rel_soc: soc = np.array(soc) - # if the rotation starts with lower than desired soc, lift the soc + # if the rotation starts with lower than desired soc, lift the SoC if soc[rot_start_idx] < self.args.desired_soc_deps: soc = self.lift_and_clip_positive_gradient(rot_start_idx, soc, soc_upper_thresh) @@ -1071,7 +1070,7 @@ def get_low_soc_events(self, rotations: Iterable = None, filter_standing_time=Tr old_idx = min_idx i = min_idx - # find the first index by going back from the minimal SoC index, where the soc + # find the first index by going back from the minimal SoC index, where the SoC # was above the upper threshold OR the index where the rotation started. while soc[i] < soc_upper_thresh: if i <= rot_start_idx: @@ -1082,7 +1081,7 @@ def get_low_soc_events(self, rotations: Iterable = None, filter_standing_time=Tr start = i i = min_idx - # do the same as before but find the index after the minimal soc. + # do the same as before but find the index after the minimal SoC. while soc[i] < soc_upper_thresh: if i >= rot_end_idx - 1: break @@ -1138,7 +1137,7 @@ def get_low_soc_events(self, rotations: Iterable = None, filter_standing_time=Tr # if the mask does not leave any value, the loop is finished if not np.any(mask): break - # Check the remaining unmasked socs for the minimal soc + # Check the remaining unmasked SoCs for the minimal SoC min_soc, min_idx = get_min_soc_and_index(soc_idx, mask) return events @@ -1150,15 +1149,15 @@ def lift_and_clip_positive_gradient(self, start_idx: int, soc: np.array, Lifts a given SoC array to the value of soc_upper_thresh from the start_index to the end. Values which exceed soc_upper_thresh and have a positive gradient are clipped to soc_upper_thresh. If values exist which exceed soc_upper_thresh and have negative gradients, - they and the following socs have this delta subtracted instead. This is the behavior of + they and the following SoCs have this delta subtracted instead. This is the behavior of approximate behaviour of charging a vehicle above 100%. Values can not exceed 100%. As soon as discharge happens, the value drops. :param start_idx: index from where the array is lifted :type start_idx: int - :param soc: array which contains socs to be lifted + :param soc: array which contains SoCs to be lifted :type soc: np.array - :param soc_upper_thresh: max value of socs used for clipping + :param soc_upper_thresh: max value of SoCs used for clipping :type soc_upper_thresh: float :return: None @@ -1173,7 +1172,7 @@ def lift_and_clip_positive_gradient(self, start_idx: int, soc: np.array, # to speed up computation, only look at the affected part soc = soc[start_idx:] soc_max = np.max(soc) - # Clip soc values to soc_upper_thresh. + # Clip SoC values to soc_upper_thresh. # Multiple local maxima above this value can exist which, # if they are rising over time, need multiple clippings. while soc_max > soc_upper_thresh: @@ -1181,8 +1180,8 @@ def lift_and_clip_positive_gradient(self, start_idx: int, soc: np.array, desc = np.arange(len(soc), 0, -1) # gradient of SoC i.e. positive if charging, negative if discharging diff = np.hstack((np.diff(soc), -1)) - # masking of socs >1 and negative gradient for local maximum - # i.e. after lifting the soc, it finds the first spot where the SoC is bigger + # masking of SoCs >1 and negative gradient for local maximum + # i.e. after lifting the SoC, it finds the first spot where the SoC is bigger # than the upper threshold and descending. idc_loc_max = np.argmax(desc * (soc > 1) * (diff < 0)) diff --git a/simba/util.py b/simba/util.py index 8fe1be73..8aa0d4de 100644 --- a/simba/util.py +++ b/simba/util.py @@ -1,4 +1,5 @@ import argparse +import csv import json import logging import subprocess @@ -455,3 +456,38 @@ def daterange(start_date, end_date, time_delta): while start_date < end_date: yield start_date start_date += time_delta + + + +def get_dict_from_csv(column, file_path, index): + """ Get a dictonary with the key of a numeric index and the value of a numeric column + + :param column: column name for dictionary values. Content needs to be castable to float + :type column: str + :param file_path: file path + :type file_path: str or Path + :param index: column name of the index / keys of the dictionary. + Content needs to be castable to float + :return: dictionary with numeric keys of index and numeric values of column + """ + output = dict() + with open(file_path, "r") as f: + delim = get_csv_delim(file_path) + reader = csv.DictReader(f, delimiter=delim) + for row in reader: + output[float(row[index])] = float(row[column]) + return output + + +def cast_float_or_none(val: any) -> any: + """ Cast a value to float. If a ValueError or TypeError is raised, None is returned + + :param val: value to cast + :type val: any + :return: casted value + """ + + try: + return float(val) + except (ValueError, TypeError): + return None \ No newline at end of file diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 48e88289..2220993f 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -88,12 +88,12 @@ def test_station_data_reading(self, caplog): # check if reading a non valid station.csv throws warnings args.station_data_path = file_root / "not_existent_file" - data_container = DataContainer().fill_with_args(args) - assert len(caplog.records) == 1 + with pytest.raises(FileNotFoundError): + data_container = DataContainer().fill_with_args(args) args.station_data_path = file_root / "not_numeric_stations.csv" - data_container = DataContainer().fill_with_args(args) - assert len(caplog.records) == 2 + with pytest.raises(ValueError): + data_container = DataContainer().fill_with_args(args) def test_basic_run(self): """ Check if running a basic example works and if a scenario object is returned diff --git a/tests/test_soc_dispatcher.py b/tests/test_soc_dispatcher.py index 6980f4e7..437d48c5 100644 --- a/tests/test_soc_dispatcher.py +++ b/tests/test_soc_dispatcher.py @@ -78,42 +78,41 @@ def basic_run(self): return sched, scen, args @pytest.fixture - def eflips_output(self): - # eflipsoutput - eflips_output = [] - - eflips_output.append(dict(rot="41", v_id="AB_depb_1", soc=1)) - eflips_output.append(dict(rot="4", v_id="AB_depb_1", soc=1)) - eflips_output.append(dict(rot="3", v_id="AB_depb_2", soc=0.8)) - eflips_output.append(dict(rot="31", v_id="AB_depb_2", soc=0.8)) - eflips_output.append(dict(rot="21", v_id="AB_depb_3", soc=0.69)) - eflips_output.append(dict(rot="2", v_id="AB_depb_3", soc=1)) - eflips_output.append(dict(rot="1", v_id="AB_oppb_1", soc=1)) - eflips_output.append(dict(rot="11", v_id="AB_oppb_2", soc=0.6)) - eflips_output.append(dict(rot="12", v_id="AB_oppb_1", soc=0.945)) - return eflips_output - - def test_basic_dispatching(self, eflips_output): + def custom_vehicle_assignment(self): + custom_vehicle_assignment = [] + + custom_vehicle_assignment.append(dict(rot="41", v_id="AB_depb_1", soc=1)) + custom_vehicle_assignment.append(dict(rot="4", v_id="AB_depb_1", soc=1)) + custom_vehicle_assignment.append(dict(rot="3", v_id="AB_depb_2", soc=0.8)) + custom_vehicle_assignment.append(dict(rot="31", v_id="AB_depb_2", soc=0.8)) + custom_vehicle_assignment.append(dict(rot="21", v_id="AB_depb_3", soc=0.69)) + custom_vehicle_assignment.append(dict(rot="2", v_id="AB_depb_3", soc=1)) + custom_vehicle_assignment.append(dict(rot="1", v_id="AB_oppb_1", soc=1)) + custom_vehicle_assignment.append(dict(rot="11", v_id="AB_oppb_2", soc=0.6)) + custom_vehicle_assignment.append(dict(rot="12", v_id="AB_oppb_1", soc=0.945)) + return custom_vehicle_assignment + + def test_basic_dispatching(self, custom_vehicle_assignment): """Returns a schedule, scenario and args after running SimBA. :param eflips_output: list of eflips data """ sched, scen, args = self.basic_run() pd.DataFrame(scen.vehicle_socs).plot() - sched.assign_vehicles_for_django(eflips_output) + sched.assign_vehicles_custom(custom_vehicle_assignment) scen = sched.run(args) pd.DataFrame(scen.vehicle_socs).plot() - def test_basic_missing_rotation(self, eflips_output): + def test_basic_missing_rotation(self, custom_vehicle_assignment): """Test if missing a rotation throws an error :param eflips_output: list of eflips data """ sched, scen, args = self.basic_run() # delete data for a single rotation but keep the rotation_id - missing_rot_id = eflips_output[-1]["rot"] - del eflips_output[-1] + missing_rot_id = custom_vehicle_assignment[-1]["rot"] + del custom_vehicle_assignment[-1] # if data for a rotation is missing an error containing the rotation id should be raised with pytest.raises(KeyError, match=missing_rot_id): - sched.assign_vehicles_for_django(eflips_output) + sched.assign_vehicles_custom(custom_vehicle_assignment) From f9b0dbdaa7725121d7beb38ae7fba4ea2de26270 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 13 Jun 2024 16:11:23 +0200 Subject: [PATCH 43/68] Make flake8 happy --- simba/data_container.py | 2 -- simba/ids.py | 2 +- simba/schedule.py | 7 +++---- simba/util.py | 3 +-- tests/test_soc_dispatcher.py | 4 ++-- 5 files changed, 7 insertions(+), 11 deletions(-) diff --git a/simba/data_container.py b/simba/data_container.py index dfb0657f..bf196bc2 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -327,5 +327,3 @@ def get_values_from_nested_key(key, data: dict) -> list: for value in data.values(): if isinstance(value, dict): yield from get_values_from_nested_key(key, value) - - diff --git a/simba/ids.py b/simba/ids.py index 47532b81..2db828ca 100644 --- a/simba/ids.py +++ b/simba/ids.py @@ -8,4 +8,4 @@ TEMPERATURE = "temperature" LONGITUDE = "lng" LATITUDE = "lat" -ELEVATION = "elevation" \ No newline at end of file +ELEVATION = "elevation" diff --git a/simba/schedule.py b/simba/schedule.py index 77ec396f..a8660346 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -5,7 +5,7 @@ from pathlib import Path import random import warnings -from typing import Dict, Type, Iterable +from typing import Iterable import simba.rotation import spice_ev.strategy @@ -27,7 +27,6 @@ class SocDispatcher: departure changed. Used for specific vehicle initialization, for example, when coupling tools.""" - def __init__(self, default_soc_deps, default_soc_opps, vehicle_socs=None): """ :param default_soc_deps: default desired SoC at departure for depot charger @@ -36,7 +35,6 @@ def __init__(self, default_soc_deps, default_soc_opps, vehicle_socs=None): [vehicle_id][previous_trip], since this is how the SpiceEV scenario is generated. The first trip of a vehicle has no previous trip. In this case, the trip key is None. :type vehicle_socs: Dict[str, Type[Dict["simba.trip.Trip", float]]] - :return: None """ self.default_soc_deps = default_soc_deps self.default_soc_opps = default_soc_opps @@ -703,7 +701,8 @@ def get_height_difference(self, departure_name, arrival_name): end_height = self.station_data[station_name]["elevation"] return end_height - start_height except KeyError: - logging.error(f"No elevation data found for {station_name}. Height difference set to 0") + logging.error( + f"No elevation data found for {station_name}. Height difference set to 0") else: logging.error("No station data found for schedule. Height difference set to 0") return 0 diff --git a/simba/util.py b/simba/util.py index 8aa0d4de..266b9c56 100644 --- a/simba/util.py +++ b/simba/util.py @@ -458,7 +458,6 @@ def daterange(start_date, end_date, time_delta): start_date += time_delta - def get_dict_from_csv(column, file_path, index): """ Get a dictonary with the key of a numeric index and the value of a numeric column @@ -490,4 +489,4 @@ def cast_float_or_none(val: any) -> any: try: return float(val) except (ValueError, TypeError): - return None \ No newline at end of file + return None diff --git a/tests/test_soc_dispatcher.py b/tests/test_soc_dispatcher.py index 437d48c5..08275596 100644 --- a/tests/test_soc_dispatcher.py +++ b/tests/test_soc_dispatcher.py @@ -94,7 +94,7 @@ def custom_vehicle_assignment(self): def test_basic_dispatching(self, custom_vehicle_assignment): """Returns a schedule, scenario and args after running SimBA. - :param eflips_output: list of eflips data + :param custom_vehicle_assignment: list of assignments """ sched, scen, args = self.basic_run() pd.DataFrame(scen.vehicle_socs).plot() @@ -106,7 +106,7 @@ def test_basic_dispatching(self, custom_vehicle_assignment): def test_basic_missing_rotation(self, custom_vehicle_assignment): """Test if missing a rotation throws an error - :param eflips_output: list of eflips data + :param custom_vehicle_assignment: list of assignments """ sched, scen, args = self.basic_run() # delete data for a single rotation but keep the rotation_id From a3815ba5fd84b73ddf264fe88d13cad159c00138 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Wed, 26 Jun 2024 14:01:04 +0200 Subject: [PATCH 44/68] Implement review changes --- simba/consumption.py | 4 +- simba/data_container.py | 20 ++-- simba/ids.py | 1 + simba/schedule.py | 28 ++--- simba/simulate.py | 57 ++++----- simba/station_optimization.py | 2 +- simba/util.py | 210 +++++++++++++++++----------------- tests/test_simulate.py | 2 - 8 files changed, 153 insertions(+), 171 deletions(-) diff --git a/simba/consumption.py b/simba/consumption.py index 6928b64f..3a795863 100644 --- a/simba/consumption.py +++ b/simba/consumption.py @@ -97,13 +97,13 @@ def set_consumption_interpolation(self, consumption_lookup_name: str, df: pd.Dat interpol_function = self.get_nd_interpolation(df_vt) vt_specific_name = self.get_consumption_lookup_name(consumption_lookup_name, vt) if vt_specific_name in self.consumption_interpolation: - warnings.warn("Overwriting exising consumption function") + warnings.warn(f"Overwriting exising consumption function {vt_specific_name}.") self.consumption_interpolation.update({vt_specific_name: interpol_function}) return interpol_function = self.get_nd_interpolation(df) if consumption_lookup_name in self.consumption_interpolation: - warnings.warn("Overwriting exising consumption function") + warnings.warn(f"Overwriting exising consumption function {consumption_lookup_name}.") self.consumption_interpolation.update({consumption_lookup_name: interpol_function}) def get_nd_interpolation(self, df): diff --git a/simba/data_container.py b/simba/data_container.py index bf196bc2..1643342b 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -14,15 +14,15 @@ class DataContainer: def __init__(self): - # Dictionary of dict[VehicleTypeName][ChargingType] containing the vehicle_info dictionary + # Dictionary VehicleTypeName -> ChargingTypeName -> VehicleInfo self.vehicle_types_data: Dict[str, any] = {} - # Dictionary of dict[consumption_lookup_name] containing a consumption lookup + # Dictionary ConsumptionName -> Consumption lookup self.consumption_data: Dict[str, pd.DataFrame] = {} - # Dictionary of dict[hour_of_day] containing temperature in °C + # Dictionary hour_of_day -> temperature in °C self.temperature_data: Dict[int, float] = {} - # Dictionary of dict[hour_of_day] containing level of loading [-] + # Dictionary hour_of_day -> level of loading [-] self.level_of_loading_data: Dict[int, float] = {} - # Dictionary of dict[station_name] containing information about electrification + # Dictionary station_name -> information about electrification self.stations_data: Dict[str, dict] = {} # Dictionary containing various infos about investment costs and grid operator self.cost_parameters_data: Dict[str, dict] = {} @@ -131,20 +131,20 @@ def add_station_geo_data_from_csv(self, file_path: Path) -> 'DataContainer': # this data is stored in the schedule and passed to the trips, which use the information # for consumption calculation. Missing station data is handled with default values. self.station_geo_data = dict() - line_num = None try: with open(file_path, "r", encoding='utf-8') as f: delim = util.get_csv_delim(file_path) reader = csv.DictReader(f, delimiter=delim) for line_num, row in enumerate(reader): - self.station_geo_data[str(row['Endhaltestelle'])] = { + self.station_geo_data[str(row[ids.STOP_NAME])] = { ids.ELEVATION: float(row[ids.ELEVATION]), ids.LATITUDE: float(row.get(ids.LATITUDE, 0)), ids.LONGITUDE: float(row.get(ids.LONGITUDE, 0)), } except (FileNotFoundError, KeyError): logging.log(msg=f"External csv file {file_path} not found or not named properly. " - "(Needed column names are 'Endhaltestelle' and 'elevation')", + f"(Needed column names are {ids.STOP_NAME} and {ids.ELEVATION}. " + f"Optional column names are {ids.LATITUDE} and {ids.LONGITUDE}.)", level=100) raise except ValueError: @@ -303,8 +303,8 @@ def add_consumption_data(self, data_name, df: pd.DataFrame) -> 'DataContainer': :type df: pd.DataFrame :return: DatacContainer instance with added consumption data """ - missing_cols = [c for c in [INCLINE, T_AMB, LEVEL_OF_LOADING, SPEED, CONSUMPTION] if - c not in df.columns] + missing_cols = [c for c in [INCLINE, T_AMB, LEVEL_OF_LOADING, SPEED, CONSUMPTION] + if c not in df.columns] assert not missing_cols, f"Consumption data is missing {', '.join(missing_cols)}" assert data_name not in self.consumption_data, f"{data_name} already exists in data" self.consumption_data[data_name] = df diff --git a/simba/ids.py b/simba/ids.py index 2db828ca..ba40b953 100644 --- a/simba/ids.py +++ b/simba/ids.py @@ -9,3 +9,4 @@ LONGITUDE = "lng" LATITUDE = "lat" ELEVATION = "elevation" +STOP_NAME = "Endhaltestelle" diff --git a/simba/schedule.py b/simba/schedule.py index a8660346..0729bd33 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -108,8 +108,7 @@ def from_datacontainer(cls, data: DataContainer, args): for trip in data.trip_data: rotation_id = trip['rotation_id'] - # get average hour of trip and parse to string, since tabular data has strings - # as keys + # get average hour of trip and parse to string, since tabular data has strings as keys hour = (trip["departure_time"] + (trip["arrival_time"] - trip["departure_time"]) / 2).hour @@ -134,9 +133,8 @@ def from_datacontainer(cls, data: DataContainer, args): schedule=schedule)}) schedule.rotations[rotation_id].add_trip(trip) - # set charging type for all rotations without explicitly specified charging type. - # charging type may have been set above if a trip of a rotation has a specified - # charging type + # Set charging type for all rotations without explicitly specified charging type. + # Charging type may have been set previously if a trip had specified a charging type. for rot in schedule.rotations.values(): if rot.charging_type is None: rot.set_charging_type(ct=vars(args).get('preferred_charging_type', 'depb')) @@ -991,17 +989,15 @@ def generate_scenario(self, args): departure_station_type = self.stations[gc_name]["type"] except KeyError: departure_station_type = "deps" - - vehicles[vehicle_id] = { - "connected_charging_station": None, - "estimated_time_of_departure": trip.departure_time.isoformat(), - "desired_soc": None, - "soc": self.soc_dispatcher.get_soc(vehicle_id=vehicle_id, - trip=None, - station_type=departure_station_type), - "vehicle_type": - f"{trip.rotation.vehicle_type}_{trip.rotation.charging_type}" - } + vehicles[vehicle_id] = { + "connected_charging_station": None, + "estimated_time_of_departure": trip.departure_time.isoformat(), + "desired_soc": None, + "soc": self.soc_dispatcher.get_soc( + vehicle_id=vehicle_id, trip=None, station_type=departure_station_type), + "vehicle_type": + f"{trip.rotation.vehicle_type}_{trip.rotation.charging_type}" + } # create departure event events["vehicle_events"].append({ diff --git a/simba/simulate.py b/simba/simulate.py index 265187db..d9c07303 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -44,7 +44,7 @@ def pre_simulation(args, data_container: DataContainer): :return: schedule, args :rtype: simba.schedule.Schedule, Namespace """ - # Deepcopy args so original args do not get mutated, i.e. deleted + # Deepcopy args so original args do not get mutated args = deepcopy(args) # generate schedule from csv @@ -135,20 +135,22 @@ class Mode: Optionally, an index of the current mode in the modelist can be given. A function must return the updated schedule and scenario objects. """ - @staticmethod - def sim_greedy(schedule, scenario, args, _i):# Noqa - scenario = schedule.run(args, mode="greedy") - return schedule, scenario - @staticmethod def sim(schedule, scenario, args, _i):# Noqa # Base simulation function for external access. + # No effect when used directly in SimBA" scenario = schedule.run(args, mode="distributed") return schedule, scenario + @staticmethod + def sim_greedy(schedule, scenario, args, _i):# Noqa + # Run a basic greedy simulation without depb/oppb distinction + scenario = schedule.run(args, mode="greedy") + return schedule, scenario + @staticmethod def service_optimization(schedule, scenario, args, _i): - # find largest set of rotations that produce no negative SoC + # Find largest set of rotations that produce no negative SoC result = optimization.service_optimization(schedule, scenario, args) schedule, scenario = result['optimized'] if scenario is None: @@ -192,26 +194,31 @@ def switch_type(schedule, scenario, args, from_type, to_type): return schedule, scenario @staticmethod - def station_optimization(schedule, scenario, args, i): - conf = optimizer_util.OptimizerConfig() - if args.optimizer_config: - conf = read_optimizer_config(args.optimizer_config) - else: + def _station_optimization(schedule, scenario, args, _i, single_step: bool): + if not args.optimizer_config: logging.warning("Station optimization needs an optimization config file. " "Default Config is used.") - - # Get copies of the original schedule and scenario. In case of an exception the outer + conf = optimizer_util.OptimizerConfig().set_defaults() + else: + conf = read_optimizer_config(args.optimizer_config) + if single_step: + conf.early_return = True + # Work on copies of the original schedule and scenario. In case of an exception the outer # schedule and scenario stay intact. original_schedule = deepcopy(schedule) original_scenario = deepcopy(scenario) try: - create_results_directory(args, i+1) + create_results_directory(args, _i+1) return run_optimization(conf, sched=schedule, scen=scenario, args=args) except Exception as err: logging.warning('During Station optimization an error occurred {0}. ' 'Optimization was skipped'.format(err)) return original_schedule, original_scenario + @staticmethod + def station_optimization(schedule, scenario, args, i): + return Mode._station_optimization(schedule, scenario, args, i, False) + @staticmethod def station_optimization_single_step(schedule, scenario, args, i): """ Electrify only the station with the highest potential @@ -226,25 +233,7 @@ def station_optimization_single_step(schedule, scenario, args, i): :return: schedule, scenario """ # noqa - if not args.optimizer_config: - logging.warning("Station optimization needs an optimization config file. " - "Default Config is used.") - conf = optimizer_util.OptimizerConfig().set_defaults() - else: - conf = read_optimizer_config(args.optimizer_config) - - conf.early_return = True - # Work on copies of the original schedule and scenario. In case of an exception the outer - # schedule and scenario stay intact. - original_schedule = deepcopy(schedule) - original_scenario = deepcopy(scenario) - try: - create_results_directory(args, i+1) - return run_optimization(conf, sched=schedule, scen=scenario, args=args) - except Exception as err: - logging.warning('During Station optimization an error occurred {0}. ' - 'Optimization was skipped'.format(err)) - return original_schedule, original_scenario + return Mode._station_optimization(schedule, scenario, args, i, True) @staticmethod def remove_negative(schedule, scenario, args, _i): diff --git a/simba/station_optimization.py b/simba/station_optimization.py index 66c9412a..7fcf6cdd 100644 --- a/simba/station_optimization.py +++ b/simba/station_optimization.py @@ -123,7 +123,7 @@ def run_optimization(conf, sched=None, scen=None, args=None): r for r in sched.rotations if "depb" == sched.rotations[r].charging_type) sched.rotations = {r: sched.rotations[r] for r in sched.rotations if "oppb" == sched.rotations[r].charging_type} - if len(sched.rotations) < 1: + if len(sched.rotations) == 0: raise Exception("No rotations left after removing depot chargers") # rebasing the scenario meaning simulating it again with SpiceEV and the given conditions of diff --git a/simba/util.py b/simba/util.py index 266b9c56..e680343a 100644 --- a/simba/util.py +++ b/simba/util.py @@ -17,52 +17,6 @@ def save_version(file_path): f.write("Git Hash SimBA:" + get_git_revision_hash()) -def get_buffer_time(trip, default=0): - """ Get buffer time at arrival station of a trip. - - Buffer time is an abstraction of delays like - docking procedures and is added to the planned arrival time. - - :param trip: trip to calculate buffer time for - :type trip: simba.Trip - :param default: Default buffer time if no station specific buffer time is given. [minutes] - :type default: dict, numeric - :return: buffer time in minutes - :rtype: dict or int - - NOTE: Buffer time dictionaries map hours of the day to a buffer time. - Keys are ranges of hours and corresponding values provide buffer time in - minutes for that time range. - An entry with key "else" is a must if not all hours of the day are covered. - Example: ``buffer_time = {"10-22": 2, "22-6": 3, "else": 1}`` - """ - - schedule = trip.rotation.schedule - buffer_time = schedule.stations.get(trip.arrival_name, {}).get('buffer_time', default) - - # distinct buffer times depending on time of day can be provided - # in that case buffer time is of type dict instead of int - if isinstance(buffer_time, dict): - # sort dict to make sure 'else' key is last key - buffer_time = {key: buffer_time[key] for key in sorted(buffer_time)} - current_hour = trip.arrival_time.hour - for time_range, buffer in buffer_time.items(): - if time_range == 'else': - buffer_time = buffer - break - else: - start_hour, end_hour = [int(t) for t in time_range.split('-')] - if end_hour < start_hour: - if current_hour >= start_hour or current_hour < end_hour: - buffer_time = buffer - break - else: - if start_hour <= current_hour < end_hour: - buffer_time = buffer - break - return buffer_time - - def uncomment_json_file(f, char='//'): """ Remove comments from JSON file. @@ -138,6 +92,26 @@ def get_csv_delim(path, other_delims=set()): return "," +def get_dict_from_csv(column, file_path, index): + """ Get a dictonary with the key of a numeric index and the value of a numeric column + + :param column: column name for dictionary values. Content needs to be castable to float + :type column: str + :param file_path: file path + :type file_path: str or Path + :param index: column name of the index / keys of the dictionary. + Content needs to be castable to float + :return: dictionary with numeric keys of index and numeric values of column + """ + output = dict() + with open(file_path, "r") as f: + delim = get_csv_delim(file_path) + reader = csv.DictReader(f, delimiter=delim) + for row in reader: + output[float(row[index])] = float(row[column]) + return output + + def nd_interp(input_values, lookup_table): """ Interpolates a value from a table. @@ -221,6 +195,37 @@ def nd_interp(input_values, lookup_table): return points[0][-1] +def daterange(start_date, end_date, time_delta): + """ Iterate over a datetime range using a time_delta step. + + Like range(), the end_value is excluded. + :param start_date: first value of iteration + :type start_date: datetime.datetime + :param end_date: excluded end value of iteration + :type end_date: datetime.datetime + :param time_delta: step size of iteration + :type time_delta: datetime.timedelta + :yields: iterated value + :rtype: Iterator[datetime.datetime] + """ + while start_date < end_date: + yield start_date + start_date += time_delta + + +def cast_float_or_none(val: any) -> any: + """ Cast a value to float. If a ValueError or TypeError is raised, None is returned + + :param val: value to cast + :type val: any + :return: casted value + """ + try: + return float(val) + except (ValueError, TypeError): + return None + + def setup_logging(args, time_str): """ Setup logging. @@ -255,6 +260,59 @@ def setup_logging(args, time_str): logging.captureWarnings(True) +def get_buffer_time(trip, default=0): + """ Get buffer time at arrival station of a trip. + + Buffer time is an abstraction of delays like + docking procedures and is added to the planned arrival time. + + :param trip: trip to calculate buffer time for + :type trip: simba.Trip + :param default: Default buffer time if no station specific buffer time is given. [minutes] + :type default: dict, numeric + :return: buffer time in minutes + :rtype: dict or int + + NOTE: Buffer time dictionaries map hours of the day to a buffer time. + Keys are ranges of hours and corresponding values provide buffer time in + minutes for that time range. + An entry with key "else" is a must if not all hours of the day are covered. + Example: ``buffer_time = {"10-22": 2, "22-6": 3, "else": 1}`` + """ + + schedule = trip.rotation.schedule + buffer_time = schedule.stations.get(trip.arrival_name, {}).get('buffer_time', default) + + # distinct buffer times depending on time of day can be provided + # in that case buffer time is of type dict instead of int + if isinstance(buffer_time, dict): + # sort dict to make sure 'else' key is last key + buffer_time = {key: buffer_time[key] for key in sorted(buffer_time)} + current_hour = trip.arrival_time.hour + for time_range, buffer in buffer_time.items(): + if time_range == 'else': + buffer_time = buffer + break + else: + start_hour, end_hour = [int(t) for t in time_range.split('-')] + if end_hour < start_hour: + if current_hour >= start_hour or current_hour < end_hour: + buffer_time = buffer + break + else: + if start_hour <= current_hour < end_hour: + buffer_time = buffer + break + return buffer_time + + +def mutate_args_for_spiceev(args): + # arguments relevant to SpiceEV, setting automatically to reduce clutter in config + args.margin = 1 + args.ALLOW_NEGATIVE_SOC = True + args.PRICE_THRESHOLD = -100 # ignore price for charging decisions + + def get_args(): parser = get_parser() @@ -277,14 +335,6 @@ def get_args(): return args -def mutate_args_for_spiceev(args): - # arguments relevant to SpiceEV, setting automatically to reduce clutter in config - args.strategy = 'distributed' - args.margin = 1 - args.ALLOW_NEGATIVE_SOC = True - args.PRICE_THRESHOLD = -100 # ignore price for charging decisions - - def get_parser(): parser = argparse.ArgumentParser( description='SimBA - Simulation toolbox for Bus Applications.') @@ -438,55 +488,3 @@ def get_parser(): parser.add_argument('--config', help='Use config file to set arguments') return parser - - -def daterange(start_date, end_date, time_delta): - """ Iterate over a datetime range using a time_delta step. - - Like range(), the end_value is excluded. - :param start_date: first value of iteration - :type start_date: datetime.datetime - :param end_date: excluded end value of iteration - :type end_date: datetime.datetime - :param time_delta: step size of iteration - :type time_delta: datetime.timedelta - :yields: iterated value - :rtype: Iterator[datetime.datetime] - """ - while start_date < end_date: - yield start_date - start_date += time_delta - - -def get_dict_from_csv(column, file_path, index): - """ Get a dictonary with the key of a numeric index and the value of a numeric column - - :param column: column name for dictionary values. Content needs to be castable to float - :type column: str - :param file_path: file path - :type file_path: str or Path - :param index: column name of the index / keys of the dictionary. - Content needs to be castable to float - :return: dictionary with numeric keys of index and numeric values of column - """ - output = dict() - with open(file_path, "r") as f: - delim = get_csv_delim(file_path) - reader = csv.DictReader(f, delimiter=delim) - for row in reader: - output[float(row[index])] = float(row[column]) - return output - - -def cast_float_or_none(val: any) -> any: - """ Cast a value to float. If a ValueError or TypeError is raised, None is returned - - :param val: value to cast - :type val: any - :return: casted value - """ - - try: - return float(val) - except (ValueError, TypeError): - return None diff --git a/tests/test_simulate.py b/tests/test_simulate.py index af2b3b94..cdd5f2a4 100644 --- a/tests/test_simulate.py +++ b/tests/test_simulate.py @@ -143,7 +143,6 @@ def test_mode_report(self, tmp_path): args.mode = "report" args.cost_calculation = True args.output_directory = tmp_path - args.strategy = "distributed" args.strategy_deps = "balanced" args.strategy_opps = "greedy" @@ -162,7 +161,6 @@ def test_empty_report(self, tmp_path): args.ALLOW_NEGATIVE_SOC = True args.cost_calculation = True args.output_directory = tmp_path - args.strategy = "distributed" args.show_plots = False with warnings.catch_warnings(): warnings.simplefilter("ignore") From 0743502d5ae83e97bf622849d0f0acef28b52835 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 4 Jul 2024 09:37:18 +0200 Subject: [PATCH 45/68] Implement review changes --- simba/simulate.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/simba/simulate.py b/simba/simulate.py index d9c07303..2b28568e 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -150,7 +150,8 @@ def sim_greedy(schedule, scenario, args, _i):# Noqa @staticmethod def service_optimization(schedule, scenario, args, _i): - # Find largest set of rotations that produce no negative SoC + """ Find largest set of rotations that produce no negative SoC. + """ # noqa result = optimization.service_optimization(schedule, scenario, args) schedule, scenario = result['optimized'] if scenario is None: @@ -194,7 +195,7 @@ def switch_type(schedule, scenario, args, from_type, to_type): return schedule, scenario @staticmethod - def _station_optimization(schedule, scenario, args, _i, single_step: bool): + def _station_optimization(schedule, scenario, args, i, single_step: bool): if not args.optimizer_config: logging.warning("Station optimization needs an optimization config file. " "Default Config is used.") @@ -208,7 +209,7 @@ def _station_optimization(schedule, scenario, args, _i, single_step: bool): original_schedule = deepcopy(schedule) original_scenario = deepcopy(scenario) try: - create_results_directory(args, _i+1) + create_results_directory(args, i + 1) return run_optimization(conf, sched=schedule, scen=scenario, args=args) except Exception as err: logging.warning('During Station optimization an error occurred {0}. ' @@ -217,7 +218,7 @@ def _station_optimization(schedule, scenario, args, _i, single_step: bool): @staticmethod def station_optimization(schedule, scenario, args, i): - return Mode._station_optimization(schedule, scenario, args, i, False) + return Mode._station_optimization(schedule, scenario, args, i, sinlge_step=False) @staticmethod def station_optimization_single_step(schedule, scenario, args, i): @@ -233,7 +234,7 @@ def station_optimization_single_step(schedule, scenario, args, i): :return: schedule, scenario """ # noqa - return Mode._station_optimization(schedule, scenario, args, i, True) + return Mode._station_optimization(schedule, scenario, args, i, single_step=True) @staticmethod def remove_negative(schedule, scenario, args, _i): From 70f87e0732a885f6d69719d29b29bf24a76ae7ed Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 4 Jul 2024 11:38:35 +0200 Subject: [PATCH 46/68] Give all trips durations --- data/examples/trips_example.csv | 26 +++++++++++++------------- simba/schedule.py | 3 ++- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/data/examples/trips_example.csv b/data/examples/trips_example.csv index 8e1d2ed8..039690bc 100644 --- a/data/examples/trips_example.csv +++ b/data/examples/trips_example.csv @@ -1,5 +1,5 @@ rotation_id,line,departure_name,departure_time,arrival_time,arrival_name,distance,vehicle_type,temperature,level_of_loading,charging_type -1,LINE_0,Station-0,2022-03-07 21:31:00,2022-03-07 21:31:00,Station-1,0.06,AB,20,0,oppb +1,LINE_0,Station-0,2022-03-07 21:30:00,2022-03-07 21:31:00,Station-1,0.06,AB,20,0,oppb 1,LINE_0,Station-1,2022-03-07 21:31:00,2022-03-07 22:04:00,Station-2,14519,AB,-5,0.9,oppb 1,LINE_0,Station-2,2022-03-07 22:08:00,2022-03-07 22:43:00,Station-1,13541,AB,,,oppb 1,LINE_0,Station-1,2022-03-07 22:51:00,2022-03-07 23:24:00,Station-2,14519,AB,,,oppb @@ -12,8 +12,8 @@ rotation_id,line,departure_name,departure_time,arrival_time,arrival_name,distanc 1,LINE_1,Station-4,2022-03-08 02:48:00,2022-03-08 03:06:00,Station-3,9067,AB,,,oppb 1,LINE_1,Station-3,2022-03-08 03:19:00,2022-03-08 03:42:00,Station-4,8.36,AB,,,oppb 1,LINE_1,Station-4,2022-03-08 03:48:00,2022-03-08 04:06:00,Station-3,9067,AB,,,oppb -1,LINE_1,Station-3,2022-03-08 04:06:00,2022-03-08 04:06:00,Station-0,0.06,AB,,,oppb -2,LINE_0,Station-0,2022-03-07 22:11:00,2022-03-07 22:11:00,Station-1,0.06,AB,,, +1,LINE_1,Station-3,2022-03-08 04:06:00,2022-03-08 04:07:00,Station-0,0.06,AB,,,oppb +2,LINE_0,Station-0,2022-03-07 22:10:00,2022-03-07 22:11:00,Station-1,0.06,AB,,, 2,LINE_0,Station-1,2022-03-07 22:11:00,2022-03-07 22:44:00,Station-2,14519,AB,,, 2,LINE_0,Station-2,2022-03-07 22:48:00,2022-03-07 23:23:00,Station-1,13541,AB,,, 2,LINE_0,Station-1,2022-03-07 23:31:00,2022-03-08 00:04:00,Station-2,14519,AB,,, @@ -26,7 +26,7 @@ rotation_id,line,departure_name,departure_time,arrival_time,arrival_name,distanc 2,LINE_1,Station-3,2022-03-08 02:49:00,2022-03-08 03:12:00,Station-4,8.36,AB,,, 2,LINE_1,Station-4,2022-03-08 03:18:00,2022-03-08 03:36:00,Station-3,9067,AB,,, 2,LINE_1,Station-3,2022-03-08 03:49:00,2022-03-08 04:12:00,Station-4,8.36,AB,,, -2,LINE_1,Station-4,2022-03-08 04:12:00,2022-03-08 04:12:00,Station-0,0.06,AB,,, +2,LINE_1,Station-4,2022-03-08 04:12:00,2022-03-08 04:13:00,Station-0,0.06,AB,,, 3,LINE_2,Station-0,2022-03-07 21:06:00,2022-03-07 21:06:00,Station-6,0.06,AB,,, 3,LINE_2,Station-6,2022-03-07 21:06:00,2022-03-07 21:34:00,Station-7,13018,AB,,, 3,LINE_2,Station-7,2022-03-07 21:53:00,2022-03-07 22:13:00,Station-8,10332,AB,,, @@ -44,7 +44,7 @@ rotation_id,line,departure_name,departure_time,arrival_time,arrival_name,distanc 3,LINE_3,Station-10,2022-03-08 03:47:00,2022-03-08 04:10:00,Station-11,10579,AB,,, 3,LINE_3,Station-11,2022-03-08 04:17:00,2022-03-08 04:44:00,Station-10,12007,AB,,, 3,LINE_3,Station-10,2022-03-08 04:44:00,2022-03-08 04:54:00,Station-0,4999,AB,,, -4,LINE_2,Station-0,2022-03-07 20:26:00,2022-03-07 20:26:00,Station-6,0.06,AB,,, +4,LINE_2,Station-0,2022-03-07 20:25:00,2022-03-07 20:26:00,Station-6,0.06,AB,,, 4,LINE_2,Station-6,2022-03-07 20:26:00,2022-03-07 20:56:00,Station-12,14097,AB,,, 4,LINE_2,Station-12,2022-03-07 21:10:00,2022-03-07 21:38:00,Station-6,13.19,AB,,, 4,LINE_2,Station-6,2022-03-07 21:46:00,2022-03-07 22:14:00,Station-7,13018,AB,,, @@ -62,8 +62,8 @@ rotation_id,line,departure_name,departure_time,arrival_time,arrival_name,distanc 4,LINE_3,Station-11,2022-03-08 03:47:00,2022-03-08 04:14:00,Station-10,12007,AB,,, 4,LINE_3,Station-10,2022-03-08 04:17:00,2022-03-08 04:40:00,Station-11,10579,AB,,, 4,LINE_4,Station-11,2022-03-08 04:43:00,2022-03-08 04:58:00,Station-13,6161,AB,,, -4,LINE_4,Station-13,2022-03-08 04:58:00,2022-03-08 04:58:00,Station-0,0.06,AB,,, -11,LINE_0,Station-0,2022-03-08 21:31:00,2022-03-08 21:31:00,Station-1,0.06,AB,20,0,oppb +4,LINE_4,Station-13,2022-03-08 04:58:00,2022-03-08 04:59:00,Station-0,0.06,AB,,, +11,LINE_0,Station-0,2022-03-08 21:30:00,2022-03-08 21:31:00,Station-1,0.06,AB,20,0,oppb 11,LINE_0,Station-1,2022-03-08 21:31:00,2022-03-08 22:04:00,Station-2,14519,AB,-5,0.9,oppb 11,LINE_0,Station-2,2022-03-08 22:08:00,2022-03-08 22:43:00,Station-1,13541,AB,,,oppb 11,LINE_0,Station-1,2022-03-08 22:51:00,2022-03-08 23:24:00,Station-2,14519,AB,,,oppb @@ -76,8 +76,8 @@ rotation_id,line,departure_name,departure_time,arrival_time,arrival_name,distanc 11,LINE_1,Station-4,2022-03-09 02:48:00,2022-03-09 03:06:00,Station-3,9067,AB,,,oppb 11,LINE_1,Station-3,2022-03-09 03:19:00,2022-03-09 03:42:00,Station-4,8.36,AB,,,oppb 11,LINE_1,Station-4,2022-03-09 03:48:00,2022-03-09 04:06:00,Station-3,9067,AB,,,oppb -11,LINE_1,Station-3,2022-03-09 04:06:00,2022-03-09 04:06:00,Station-0,0.06,AB,,,oppb -21,LINE_0,Station-0,2022-03-08 22:11:00,2022-03-08 22:11:00,Station-1,0.06,AB,,, +11,LINE_1,Station-3,2022-03-09 04:06:00,2022-03-09 04:07:00,Station-0,0.06,AB,,,oppb +21,LINE_0,Station-0,2022-03-08 22:10:00,2022-03-08 22:11:00,Station-1,0.06,AB,,, 21,LINE_0,Station-1,2022-03-08 22:11:00,2022-03-08 22:44:00,Station-2,14519,AB,,, 21,LINE_0,Station-2,2022-03-08 22:48:00,2022-03-08 23:23:00,Station-1,13541,AB,,, 21,LINE_0,Station-1,2022-03-08 23:31:00,2022-03-09 00:04:00,Station-2,14519,AB,,, @@ -90,8 +90,8 @@ rotation_id,line,departure_name,departure_time,arrival_time,arrival_name,distanc 21,LINE_1,Station-3,2022-03-09 02:49:00,2022-03-09 03:12:00,Station-4,8.36,AB,,, 21,LINE_1,Station-4,2022-03-09 03:18:00,2022-03-09 03:36:00,Station-3,9067,AB,,, 21,LINE_1,Station-3,2022-03-09 03:49:00,2022-03-09 04:12:00,Station-4,8.36,AB,,, -21,LINE_1,Station-4,2022-03-09 04:12:00,2022-03-09 04:12:00,Station-0,0.06,AB,,, -31,LINE_2,Station-0,2022-03-08 21:06:00,2022-03-08 21:06:00,Station-6,0.06,AB,,, +21,LINE_1,Station-4,2022-03-09 04:12:00,2022-03-09 04:13:00,Station-0,0.06,AB,,, +31,LINE_2,Station-0,2022-03-08 21:05:00,2022-03-08 21:06:00,Station-6,0.06,AB,,, 31,LINE_2,Station-6,2022-03-08 21:06:00,2022-03-08 21:34:00,Station-7,13018,AB,,, 31,LINE_2,Station-7,2022-03-08 21:53:00,2022-03-08 22:13:00,Station-8,10332,AB,,, 31,LINE_2,Station-8,2022-03-08 22:30:00,2022-03-08 22:54:00,Station-7,10.48,AB,,, @@ -108,7 +108,7 @@ rotation_id,line,departure_name,departure_time,arrival_time,arrival_name,distanc 31,LINE_3,Station-10,2022-03-09 03:47:00,2022-03-09 04:10:00,Station-11,10579,AB,,, 31,LINE_3,Station-11,2022-03-09 04:17:00,2022-03-09 04:44:00,Station-10,12007,AB,,, 31,LINE_3,Station-10,2022-03-09 04:44:00,2022-03-09 04:54:00,Station-0,4999,AB,,, -41,LINE_2,Station-0,2022-03-08 20:26:00,2022-03-08 20:26:00,Station-6,0.06,AB,,, +41,LINE_2,Station-0,2022-03-08 20:25:00,2022-03-08 20:26:00,Station-6,0.06,AB,,, 41,LINE_2,Station-6,2022-03-08 20:26:00,2022-03-08 20:56:00,Station-12,14097,AB,,, 41,LINE_2,Station-12,2022-03-08 21:10:00,2022-03-08 21:38:00,Station-6,13.19,AB,,, 41,LINE_2,Station-6,2022-03-08 21:46:00,2022-03-08 22:14:00,Station-7,13018,AB,,, @@ -126,4 +126,4 @@ rotation_id,line,departure_name,departure_time,arrival_time,arrival_name,distanc 41,LINE_3,Station-11,2022-03-09 03:47:00,2022-03-09 04:14:00,Station-10,12007,AB,,, 41,LINE_3,Station-10,2022-03-09 04:17:00,2022-03-09 04:40:00,Station-11,10579,AB,,, 41,LINE_4,Station-11,2022-03-09 04:43:00,2022-03-09 04:58:00,Station-13,6161,AB,,, -41,LINE_4,Station-13,2022-03-09 04:58:00,2022-03-09 04:58:00,Station-0,0.06,AB,,, +41,LINE_4,Station-13,2022-03-09 04:58:00,2022-03-09 04:59:00,Station-0,0.06,AB,,, diff --git a/simba/schedule.py b/simba/schedule.py index 0729bd33..1492a398 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -1059,11 +1059,12 @@ def generate_scenario(self, args): } # create final dict + # n_intervals is rounded up to cover last simulation step in all cases self.scenario = { "scenario": { "start_time": start_simulation.isoformat(), "interval": interval.days * 24 * 60 + interval.seconds // 60, - "n_intervals": (stop_simulation - start_simulation) // interval + "n_intervals": abs((start_simulation- stop_simulation) // interval) }, "components": { "vehicle_types": vehicle_types_spiceev, From 86a56e6efa41ab393d23d0e27d23f6f09da3a071 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 4 Jul 2024 11:40:36 +0200 Subject: [PATCH 47/68] Fix issue with missing time step. add test --- simba/schedule.py | 2 +- tests/test_schedule.py | 50 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/simba/schedule.py b/simba/schedule.py index 1492a398..2f4883f4 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -1064,7 +1064,7 @@ def generate_scenario(self, args): "scenario": { "start_time": start_simulation.isoformat(), "interval": interval.days * 24 * 60 + interval.seconds // 60, - "n_intervals": abs((start_simulation- stop_simulation) // interval) + "n_intervals": int(abs((start_simulation - stop_simulation) // interval)) }, "components": { "vehicle_types": vehicle_types_spiceev, diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 2220993f..dfaca30f 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -1,11 +1,12 @@ +import math from argparse import Namespace from copy import deepcopy from datetime import timedelta, datetime # noqa import pytest import sys -import spice_ev.scenario as scenario from simba.simulate import pre_simulation +from spice_ev.events import VehicleEvent from tests.conftest import example_root, file_root from tests.helpers import generate_basic_schedule from simba import rotation, schedule, util @@ -58,6 +59,53 @@ def basic_run(self): class TestSchedule(BasicSchedule): + + def test_timestep(self): + # Get the parser from util. This way the test is directly coupled to the parser arguments + sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] + args = util.get_args() + data_container = DataContainer().fill_with_args(args) + + assert args.interval == 1 + + # Reduce rotations to increase simulation / test speed + some_rotation = data_container.trip_data[0]["rotation_id"] + data_container.trip_data = [trip_dict for trip_dict in data_container.trip_data + if trip_dict["rotation_id"] == some_rotation] + + first_trip = None + last_trip = None + for trip in data_container.trip_data: + if first_trip is None or trip["departure_time"] < first_trip["departure_time"]: + first_trip = trip + if last_trip is None or trip["arrival_time"] > last_trip["arrival_time"]: + last_trip = trip + + # Make sure the duration is not an integer multiple of interval + first_trip["departure_time"] -= timedelta(minutes=1) + first_trip["departure_time"] = first_trip["departure_time"].replace(second=0) + last_trip["arrival_time"] += timedelta(minutes=1) + last_trip["arrival_time"] = last_trip["arrival_time"].replace(second=30) + duration_in_s = (last_trip["arrival_time"] - first_trip["departure_time"]).total_seconds() + assert duration_in_s % (args.interval * 60) != 0 + + n_steps = math.ceil((duration_in_s + args.signal_time_dif * 60) / (args.interval * 60)) + 1 + sched, args = pre_simulation(args, data_container) + scenario = sched.generate_scenario(args) + assert n_steps == scenario.n_intervals + scenario.run("distributed", vars(args).copy()) + assert scenario.strat.current_time >= last_trip["arrival_time"] + assert all( + not isinstance(e, VehicleEvent) for e in scenario.strat.world_state.future_events) + + # Make sure that if the last step would not have been simulated, + # a vehicle_event would still be up for simulation + scenario = sched.generate_scenario(args) + scenario.n_intervals -= 1 + scenario.run("distributed", vars(args).copy()) + assert scenario.strat.current_time < last_trip["arrival_time"] + assert any(isinstance(e, VehicleEvent) for e in scenario.strat.world_state.future_events) + def test_mandatory_options_exit(self): """ Check if the schedule creation properly throws an error in case of missing mandatory options From c8668561da141561e365ad5d7ab7436f119725d5 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 4 Jul 2024 11:44:00 +0200 Subject: [PATCH 48/68] Fix missing scenario import --- tests/test_schedule.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_schedule.py b/tests/test_schedule.py index dfaca30f..1e8242f0 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -4,6 +4,7 @@ from datetime import timedelta, datetime # noqa import pytest import sys +import spice_ev.scenario as scenario from simba.simulate import pre_simulation from spice_ev.events import VehicleEvent From 1adce3763c0a85531889cce1b50a7853dec585fd Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 4 Jul 2024 11:52:28 +0200 Subject: [PATCH 49/68] Fix generate_price_list --- tests/test_schedule.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 1e8242f0..1cd2e2d7 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -594,11 +594,11 @@ def test_generate_price_lists(self): assert len(events_by_gc["Station-21"]) == 0 # run schedule and check prices - # example scenario covers 2022-03-07 20:16 - 2022-03-09 04:59 + # example scenario covers 2022-03-07 20:15 - 2022-03-09 04:59 scenario = generated_schedule.run(args) # Station-0: price change every 24h, starting midnight => two price changes at midnight assert set(scenario.prices["Station-0"]) == {0.1599, 0.18404, 0.23492} - assert scenario.prices["Station-0"][223:225] == [0.1599, 0.18404] + assert scenario.prices["Station-0"][224:226] == [0.1599, 0.18404] # Station-3: price change every hour, starting 04:00 (from csv timestamp) # => 32 price changes assert len(set(scenario.prices["Station-3"])) == 33 From 9a6623570b235b719b55687eeb0ed8e6eb373655 Mon Sep 17 00:00:00 2001 From: "stefan.schirmeister" Date: Wed, 31 Jul 2024 11:00:26 +0200 Subject: [PATCH 50/68] add option sceanrio_name for report --- data/examples/simba.cfg | 3 +++ simba/__main__.py | 7 +++++- simba/report.py | 48 ++++++++++++++++++++++++++++++----------- simba/simulate.py | 5 ++++- simba/util.py | 5 +++-- 5 files changed, 52 insertions(+), 16 deletions(-) diff --git a/data/examples/simba.cfg b/data/examples/simba.cfg index 82ef0801..39b5e553 100644 --- a/data/examples/simba.cfg +++ b/data/examples/simba.cfg @@ -1,3 +1,6 @@ +# general info: identifier of scenario, appended to results +scenario_name = example + ##### Paths ##### ### Input and output files and paths ### # Input file containing trip information (required) diff --git a/simba/__main__.py b/simba/__main__.py index a2db7b98..897ad80d 100644 --- a/simba/__main__.py +++ b/simba/__main__.py @@ -9,8 +9,13 @@ args = util.get_args() time_str = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + + if vars(args).get("scenario_name"): + dir_name = time_str + '_' + args.scenario_name + else: + dir_name = time_str if args.output_directory is not None: - args.output_directory = Path(args.output_directory) / time_str + args.output_directory = Path(args.output_directory) / dir_name # create subfolder for specific sim results with timestamp. # if folder doesn't exist, create folder. # needs to happen after set_options_from_config since diff --git a/simba/report.py b/simba/report.py index f2a2a364..20d9936c 100644 --- a/simba/report.py +++ b/simba/report.py @@ -9,15 +9,15 @@ from spice_ev.report import aggregate_global_results, plot, generate_reports -def open_for_csv(filepath): +def open_for_csv(file_path): """ Create a file handle to write to. - :param filepath: Path to new file, overwritten if existing - :type filepath: string or pathlib.Path + :param file_path: Path to new file, overwritten if existing + :type file_path: string or pathlib.Path :return: Function to open file :rtype: function """ - return open(filepath, "w", newline='', encoding='utf-8') + return open(file_path, "w", newline='', encoding='utf-8') def generate_gc_power_overview_timeseries(scenario, args): @@ -34,7 +34,11 @@ def generate_gc_power_overview_timeseries(scenario, args): if not gc_list: return - with open_for_csv(args.results_directory / "gc_power_overview_timeseries.csv") as f: + file_path = args.results_directory / "gc_power_overview_timeseries.csv" + if vars(args).get("scenario_name"): + file_path = file_path.with_stem(file_path.stem + '_' + args.scenario_name) + + with open_for_csv(file_path) as f: csv_writer = csv.writer(f) csv_writer.writerow(["time"] + gc_list) stations = [] @@ -65,8 +69,11 @@ def generate_gc_overview(schedule, scenario, args): all_gc_list = list(schedule.stations.keys()) used_gc_list = list(scenario.components.grid_connectors.keys()) stations = getattr(schedule, "stations") + file_path = args.results_directory / "gc_overview.csv" + if vars(args).get("scenario_name"): + file_path = file_path.with_stem(file_path.stem + '_' + args.scenario_name) - with open_for_csv(args.results_directory / "gc_overview.csv") as f: + with open_for_csv(file_path) as f: csv_writer = csv.writer(f) csv_writer.writerow(["station_name", "station_type", @@ -161,8 +168,14 @@ def generate_plots(scenario, args): plt.clf() plot(scenario) plt.gcf().set_size_inches(10, 10) - plt.savefig(args.results_directory / "run_overview.png") - plt.savefig(args.results_directory / "run_overview.pdf") + file_path_png = args.results_directory / "run_overview.png" + if vars(args).get("scenario_name"): + file_path_png = file_path_png.with_stem(file_path_png.stem + '_' + args.scenario_name) + plt.savefig(file_path_png) + file_path_pdf = args.results_directory / "run_overview.pdf" + if vars(args).get("scenario_name"): + file_path_pdf = file_path_pdf.with_stem(file_path_pdf.stem + '_' + args.scenario_name) + plt.savefig(file_path_pdf) if args.show_plots: plt.show() # revert logging override @@ -184,7 +197,10 @@ def generate(schedule, scenario, args): # generate simulation_timeseries.csv, simulation.json and vehicle_socs.csv in SpiceEV # re-route output paths - args.save_soc = args.results_directory / "vehicle_socs.csv" + file_path = args.results_directory / "vehicle_socs.csv" + if vars(args).get("scenario_name"): + file_path = file_path.with_stem(file_path.stem + '_' + args.scenario_name) + args.save_soc = file_path # bundle station-specific output files in subdirectory gc_dir = args.results_directory / "gcs" gc_dir.mkdir(exist_ok=True) @@ -282,10 +298,16 @@ def generate(schedule, scenario, args): t = sim_start_time + i * scenario.interval socs = [str(rotation_socs[k][i]) for k in rotations] data.append([str(t)] + socs) - write_csv(data, args.results_directory / "rotation_socs.csv", - propagate_errors=args.propagate_mode_errors) - with open_for_csv(args.results_directory / "rotation_summary.csv") as f: + file_path = args.results_directory / "rotation_socs.csv" + if vars(args).get("scenario_name"): + file_path = file_path.with_stem(file_path.stem + '_' + args.scenario_name) + write_csv(data, file_path, propagate_errors=args.propagate_mode_errors) + + file_path = args.results_directory / "rotation_summary.csv" + if vars(args).get("scenario_name"): + file_path = file_path.with_stem(file_path.stem + '_' + args.scenario_name) + with open_for_csv(file_path) as f: csv_writer = csv.DictWriter(f, list(rotation_infos[0].keys())) csv_writer.writeheader() csv_writer.writerows(rotation_infos) @@ -297,6 +319,8 @@ def generate(schedule, scenario, args): # summary of used vehicle types and all costs if args.cost_calculation: file_path = args.results_directory / "summary_vehicles_costs.csv" + if vars(args).get("scenario_name"): + file_path = file_path.with_stem(file_path.stem + '_' + args.scenario_name) csv_report = None try: csv_report = scenario.costs.to_csv_lists() diff --git a/simba/simulate.py b/simba/simulate.py index 67092284..dfb18c27 100644 --- a/simba/simulate.py +++ b/simba/simulate.py @@ -263,5 +263,8 @@ def create_results_directory(args, i): args.results_directory.mkdir(parents=True, exist_ok=True) # save used modes in report version used_modes = ['sim'] + [m for m in args.mode[:i] if m not in ['sim', 'report']] - with open(args.results_directory / "used_modes.txt", "w", encoding='utf-8') as f: + file_path = args.results_directory / "used_modes.txt" + if vars(args).get("scenario_name"): + file_path = file_path.with_stem(file_path.stem + '_' + args.scenario_name) + with open(file_path, "w", encoding='utf-8') as f: f.write(f"Used modes in this scenario: {', '.join(used_modes)}") diff --git a/simba/util.py b/simba/util.py index 2c2ffe61..0e59c040 100644 --- a/simba/util.py +++ b/simba/util.py @@ -254,10 +254,11 @@ def setup_logging(args, time_str): def get_args(): parser = argparse.ArgumentParser( description='SimBA - Simulation toolbox for Bus Applications.') + parser.add_argument('--scenario-name', help='Identifier of scenario, appended to results') # #### Paths ##### parser.add_argument('--input-schedule', - help='Path to CSV file containing all trips of schedule to be analyzed.') + help='Path to CSV file containing all trips of schedule to be analyzed') parser.add_argument('--output-directory', default="data/sim_outputs", help='Location where all simulation outputs are stored') parser.add_argument('--electrified-stations', help='include electrified_stations json') @@ -302,7 +303,7 @@ def get_args(): help='Remove rotations from schedule that violate assumptions. ') parser.add_argument('--show-plots', action='store_true', help='show plots for users to view in "report" mode') - parser.add_argument('--propagate-mode-errors', default=False, + parser.add_argument('--propagate-mode-errors', action='store_true', help='Re-raise errors instead of continuing during simulation modes') parser.add_argument('--create-scenario-file', help='Write scenario.json to file') parser.add_argument('--create-trips-in-report', action='store_true', From ff3e5fa9d4fafc00a4d4337f301b7c21b5b0cda4 Mon Sep 17 00:00:00 2001 From: "stefan.schirmeister" Date: Wed, 31 Jul 2024 12:54:26 +0200 Subject: [PATCH 51/68] add scenario_name option to simulation_parameters doc --- docs/source/simulation_parameters.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/source/simulation_parameters.rst b/docs/source/simulation_parameters.rst index de8189c0..ae2d5110 100644 --- a/docs/source/simulation_parameters.rst +++ b/docs/source/simulation_parameters.rst @@ -28,6 +28,10 @@ The example (data/simba.cfg) contains parameter descriptions which are explained - Default value - Expected values - Description + * - scenario_name + - Optional: no default given + - string + - scenario identifier, appended to output directory name and report file names * - input_schedule - Mandatory: no default given - Path as string From 20d4fd7017d47f1b74bf00584c16a55752749cb5 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 5 Sep 2024 10:40:41 +0200 Subject: [PATCH 52/68] Make string posix to avoid escape character from path to str conversion --- tests/test_example.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_example.py b/tests/test_example.py index 36aca004..170f6340 100644 --- a/tests/test_example.py +++ b/tests/test_example.py @@ -15,7 +15,8 @@ def test_example_cfg(self, tmp_path): # provide path to input data src_text = src_text.replace("data/examples", str(EXAMPLE_PATH)) # write output to tmp - src_text = re.sub(r"output_directory.+", f"output_directory = {str(tmp_path)}", src_text) + src_text = re.sub(r"output_directory.+", f"output_directory = {str(tmp_path.as_posix())}", + src_text) dst = tmp_path / "simba.cfg" # don't show plots. spaces are optional, so use regex src_text = re.sub(r"show_plots\s*=\s*true", "show_plots = false", src_text) From 9eabe0ae703f7147a0ac947ac026b0f0836123ac Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 5 Sep 2024 10:41:11 +0200 Subject: [PATCH 53/68] Git fix depot name lookup --- simba/optimization.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/simba/optimization.py b/simba/optimization.py index b7186599..acc014f1 100644 --- a/simba/optimization.py +++ b/simba/optimization.py @@ -297,7 +297,7 @@ def recombination(schedule, args, trips, depot_trips): trip.departure_name, depot_name, depot_trips, args.default_depot_distance, args.default_mean_speed) height_difference = schedule.get_height_difference( - depot_trip["name"], trip.departure_name) + depot_name, trip.departure_name) rotation.add_trip({ "departure_time": trip.departure_time - depot_trip["travel_time"], "departure_name": depot_name, @@ -332,7 +332,7 @@ def recombination(schedule, args, trips, depot_trips): trip.arrival_name, depot_name, depot_trips, args.default_depot_distance, args.default_mean_speed) height_difference = schedule.get_height_difference(trip.arrival_name, - depot_trip["name"]) + depot_name) depot_trip = { "departure_time": trip.arrival_time, "departure_name": trip.arrival_name, From d643168e8fd33f1283e5443354fb75426bcc3a38 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 5 Sep 2024 11:59:27 +0200 Subject: [PATCH 54/68] Fix tests with new consumption calculation and shortened trip data --- simba/rotation.py | 63 -------------------------------------- simba/schedule.py | 64 +++++++++++++++++++++++++++++++++++++-- tests/test_consumption.py | 27 +++++++++++++++-- tests/test_rotation.py | 25 --------------- tests/test_schedule.py | 23 ++++++++++++++ tests/test_simulate.py | 17 ++++++----- 6 files changed, 118 insertions(+), 101 deletions(-) diff --git a/simba/rotation.py b/simba/rotation.py index 5bd3c0dd..06f927e0 100644 --- a/simba/rotation.py +++ b/simba/rotation.py @@ -79,46 +79,6 @@ def add_trip(self, trip): # different CT than rotation: error raise Exception(f"Two trips of rotation {self.id} have distinct charging types") - def calculate_consumption(self): - """ Calculate consumption of this rotation and all its trips. - - :return: Consumption of rotation [kWh] - :rtype: float - """ - if len(self.trips) == 0: - self.consumption = 0 - return self.consumption - - # get the specific idle consumption of this vehicle type in kWh/h - v_info = self.schedule.vehicle_types[self.vehicle_type][self.charging_type] - - rotation_consumption = 0 - - # make sure the trips are sorted, so the next trip can be determined - self.trips = list(sorted(self.trips, key=lambda trip: trip.arrival_time)) - - trip = self.trips[0] - for next_trip in self.trips[1:]: - # get consumption due to driving - driving_consumption, driving_delta_soc = trip.calculate_consumption() - - # get idle consumption of the next break time - idle_consumption, idle_delta_soc = get_idle_consumption(trip, next_trip, v_info) - - # set trip attributes - trip.consumption = driving_consumption + idle_consumption - trip.delta_soc = driving_delta_soc + idle_delta_soc - - rotation_consumption += driving_consumption + idle_consumption - trip = next_trip - - # last trip of the rotation has no idle consumption - trip.consumption, trip.delta_soc = trip.calculate_consumption() - rotation_consumption += trip.consumption - - self.consumption = rotation_consumption - return rotation_consumption - def set_charging_type(self, ct): """ Change charging type of either all or specified rotations. @@ -186,26 +146,3 @@ def min_standing_time(self): return min_standing_time -def get_idle_consumption(first_trip: Trip, second_trip: Trip, vehicle_info: dict) -> (float, float): - """ Compute consumption while waiting for the next trip - - Calculate the idle consumption between the arrival of the first trip and the departure of the - second trip with a vehicle_info containing the keys idle_consumption and capacity. - :param first_trip: First trip - :type first_trip: Trip - :param second_trip: Second trip - :type second_trip: Trip - :param vehicle_info: Vehicle information - :type vehicle_info: dict - :return: Consumption of idling [kWh], delta_soc [-] - :rtype: float, float - """ - capacity = vehicle_info["capacity"] - idle_cons_spec = vehicle_info.get("idle_consumption", 0) - if idle_cons_spec == 0: - return 0, 0 - - break_duration = second_trip.departure_time - first_trip.arrival_time - assert break_duration.total_seconds() >= 0 - idle_consumption = break_duration.total_seconds() / 3600 * idle_cons_spec - return idle_consumption, -idle_consumption / capacity diff --git a/simba/schedule.py b/simba/schedule.py index 2f4883f4..4b2b7f7b 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -582,10 +582,43 @@ def calculate_consumption(self): def calculate_rotation_consumption(self, rotation: Rotation): rotation.consumption = 0 - for trip in rotation.trips: - rotation.consumption += self.calculate_trip_consumption(trip) + """ Calculate consumption of this rotation and all its trips. + + :return: Consumption of rotation [kWh] + :rtype: float + """ + if len(rotation.trips) == 0: + rotation.consumption = 0 + return rotation.consumption + + # get the specific idle consumption of this vehicle type in kWh/h + v_info = self.vehicle_types[rotation.vehicle_type][rotation.charging_type] + rotation_consumption = 0 + # make sure the trips are sorted, so the next trip can be determined + rotation.trips = list(sorted(rotation.trips, key=lambda trip: trip.arrival_time)) + trip = rotation.trips[0] + for next_trip in rotation.trips[1:]: + # get consumption due to driving + driving_consumption = self.calculate_trip_consumption(trip) + driving_delta_soc = trip.delta_soc + # get idle consumption of the next break time + idle_consumption, idle_delta_soc = get_idle_consumption(trip, next_trip, v_info) + + # set trip attributes + trip.consumption = driving_consumption + idle_consumption + trip.delta_soc = driving_delta_soc + idle_delta_soc + + rotation_consumption += driving_consumption + idle_consumption + trip = next_trip + + # last trip of the rotation has no idle consumption + trip.consumption = self.calculate_trip_consumption(trip) + rotation_consumption += trip.consumption + + rotation.consumption = rotation_consumption return rotation.consumption + def calculate_trip_consumption(self, trip: Trip): """ Compute consumption for this trip. @@ -1329,7 +1362,6 @@ def generate_event_list_from_prices( logging.info(f"{gc_name} price csv does not cover simulation time") return events - 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 @@ -1353,6 +1385,32 @@ def get_charge_delta_soc(charge_curves: dict, vt: str, ct: str, max_power: float return optimizer_util.get_delta_soc(charge_curve, start_soc, duration_min=duration_min) +def get_idle_consumption(first_trip: Trip, second_trip: Trip, vehicle_info: dict) -> (float, float): + """ Compute consumption while waiting for the next trip + + Calculate the idle consumption between the arrival of the first trip and the departure of the + second trip with a vehicle_info containing the keys idle_consumption and capacity. + :param first_trip: First trip + :type first_trip: Trip + :param second_trip: Second trip + :type second_trip: Trip + :param vehicle_info: Vehicle information + :type vehicle_info: dict + :return: Consumption of idling [kWh], delta_soc [-] + :rtype: float, float + """ + capacity = vehicle_info["capacity"] + idle_cons_spec = vehicle_info.get("idle_consumption", 0) + if idle_cons_spec == 0: + return 0, 0 + + break_duration = second_trip.departure_time - first_trip.arrival_time + assert break_duration.total_seconds() >= 0 + idle_consumption = break_duration.total_seconds() / 3600 * idle_cons_spec + return idle_consumption, -idle_consumption / capacity + + + def soc_at_departure_time(v_id, deps, departure_time, vehicle_data, stations, charge_curves, args): """ Get the possible SoC of the vehicle at a specified departure_time diff --git a/tests/test_consumption.py b/tests/test_consumption.py index 0cbda92c..bf6e3c26 100644 --- a/tests/test_consumption.py +++ b/tests/test_consumption.py @@ -1,7 +1,7 @@ from tests.test_schedule import BasicSchedule from tests.conftest import example_root -from datetime import datetime, timedelta -from simba.rotation import get_idle_consumption +from datetime import timedelta +from simba.schedule import get_idle_consumption import pandas as pd @@ -46,11 +46,30 @@ def test_calculate_idle_consumption(self, tmp_path): idle_consumption, idle_delta_soc = get_idle_consumption(first_trip, second_trip, v_info) assert idle_consumption == 0 + + # make all rotations depb + for r in schedule.rotations.values(): + r.set_charging_type("depb") + # make all trips of all rotations consecutive + last_trip = schedule.rotations["1"].trips[0] + + for r in schedule.rotations.values(): + r.departure_time = last_trip.arrival_time + timedelta(minutes=10) + for t in r.trips: + t.departure_time = last_trip.arrival_time + timedelta(minutes=10) + t.arrival_time = t.departure_time + timedelta(minutes=1) + last_trip = t + r.arrival_time = t.arrival_time + # Check that assignment of vehicles changes due to increased consumption. Only works # with adaptive_soc assignment for vt in schedule.vehicle_types.values(): for ct in vt: vt[ct]["idle_consumption"] = 0 + vt[ct]["mileage"] = 0 + schedule.calculate_consumption() + assert schedule.consumption == 0 + schedule.assign_vehicles_w_adaptive_soc(args) no_idle_consumption = schedule.vehicle_type_counts.copy() @@ -59,7 +78,9 @@ def test_calculate_idle_consumption(self, tmp_path): vt[ct]["idle_consumption"] = 9999 schedule.calculate_consumption() schedule.assign_vehicles_w_adaptive_soc(args) - assert no_idle_consumption["AB_depb"] * 2 == schedule.vehicle_type_counts["AB_depb"] + # Without consumption, single vehicle can service all rotations. + # With high idling, every rotation needs its own vehicle + assert no_idle_consumption["AB_depb"] * 4 == schedule.vehicle_type_counts["AB_depb"] def test_calculate_consumption(self, tmp_path): """Various tests to trigger errors and check if behaviour is as expected diff --git a/tests/test_rotation.py b/tests/test_rotation.py index 8fd1c758..c9012aa9 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -30,28 +30,3 @@ def test_set_charging_type(): consumption_depb = s.calculate_rotation_consumption(rot) assert consumption_depb * 2 == pytest.approx(consumption_oppb) - -def test_calculate_consumption(): - s = generate_basic_schedule() - vt = next(iter(s.vehicle_types)) - - r = Rotation("my_rot", vt, s) - # consumption without trips - assert r.calculate_consumption() == 0 - - some_rot = next(iter(s.rotations.values())) - first_trip = some_rot.trips[0] - first_trip.charging_type = "depb" - del first_trip.rotation - r.add_trip(vars(first_trip)) - r.trips[0].consumption = None - r.calculate_consumption() - assert r.trips[0].consumption is not None - second_trip = some_rot.trips[1] - del second_trip.rotation - r.add_trip(vars(second_trip)) - for trip in r.trips: - trip.consumption = None - r.calculate_consumption() - for trip in r.trips: - assert trip.consumption is not None diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 28a26051..2eee052b 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -686,3 +686,26 @@ def test_generate_event_list_from_prices(self): price_interval_s=3600 ) assert len(events) == 0 + + def test_rotation_consumption_calc(self): + s, args = generate_basic_schedule() + rot_iter = iter(s.rotations.values()) + r = next(rot_iter) + r.trips = [] + assert s.calculate_rotation_consumption(r) == 0 + + some_rot = next(rot_iter) + first_trip = some_rot.trips[0] + del first_trip.rotation + r.add_trip(vars(first_trip)) + r.trips[0].consumption = None + s.calculate_rotation_consumption(r) + assert r.trips[0].consumption is not None + second_trip = some_rot.trips[1] + del second_trip.rotation + r.add_trip(vars(second_trip)) + for trip in r.trips: + trip.consumption = None + s.calculate_rotation_consumption(r) + for trip in r.trips: + assert trip.consumption is not None diff --git a/tests/test_simulate.py b/tests/test_simulate.py index 2a7f5ab4..36b1a7ea 100644 --- a/tests/test_simulate.py +++ b/tests/test_simulate.py @@ -168,8 +168,8 @@ def test_empty_report(self, tmp_path): def test_create_trips_in_report(self, tmp_path): # create_trips_in_report option: must generate valid input trips.csv - values = self.DEFAULT_VALUES.copy() - values.update({ + args_dict = vars(self.get_args()) + update_dict={ "mode": ["report"], "desired_soc_deps": 0, "ALLOW_NEGATIVE_SOC": True, @@ -177,15 +177,18 @@ def test_create_trips_in_report(self, tmp_path): "output_directory": tmp_path, "show_plots": False, "create_trips_in_report": True, - }) + } + args_dict.update(update_dict) + + # simulate base scenario, report generates new trips.csv in (tmp) output with warnings.catch_warnings(): warnings.simplefilter("ignore") - simulate(Namespace(**values)) + simulate(Namespace(**args_dict)) # new simulation with generated trips.csv - values = self.DEFAULT_VALUES.copy() - values["input_schedule"] = tmp_path / "report_1/trips.csv" - simulate(Namespace(**(values))) + args = vars(self.get_args()) + args_dict["input_schedule"] = tmp_path / "report_1/trips.csv" + simulate(Namespace(**(args_dict))) def test_mode_recombination(self): args = self.get_args() From 52ad0ca736bcf46d0e133de2febfc004d6608e83 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 5 Sep 2024 12:03:43 +0200 Subject: [PATCH 55/68] Make flake8 happy --- simba/rotation.py | 2 -- simba/schedule.py | 5 ++--- tests/test_consumption.py | 1 - tests/test_rotation.py | 2 -- tests/test_schedule.py | 2 +- tests/test_simulate.py | 5 ++--- 6 files changed, 5 insertions(+), 12 deletions(-) diff --git a/simba/rotation.py b/simba/rotation.py index 06f927e0..2e821af4 100644 --- a/simba/rotation.py +++ b/simba/rotation.py @@ -144,5 +144,3 @@ def min_standing_time(self): desired_max_standing_time = ((capacity / charge_power) * min_recharge_soc) min_standing_time = min(min_standing_time, desired_max_standing_time) return min_standing_time - - diff --git a/simba/schedule.py b/simba/schedule.py index 4b2b7f7b..2b05f5f0 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -599,7 +599,7 @@ def calculate_rotation_consumption(self, rotation: Rotation): trip = rotation.trips[0] for next_trip in rotation.trips[1:]: # get consumption due to driving - driving_consumption = self.calculate_trip_consumption(trip) + driving_consumption = self.calculate_trip_consumption(trip) driving_delta_soc = trip.delta_soc # get idle consumption of the next break time idle_consumption, idle_delta_soc = get_idle_consumption(trip, next_trip, v_info) @@ -618,7 +618,6 @@ def calculate_rotation_consumption(self, rotation: Rotation): rotation.consumption = rotation_consumption return rotation.consumption - def calculate_trip_consumption(self, trip: Trip): """ Compute consumption for this trip. @@ -1362,6 +1361,7 @@ def generate_event_list_from_prices( logging.info(f"{gc_name} price csv does not cover simulation time") return events + 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 @@ -1410,7 +1410,6 @@ def get_idle_consumption(first_trip: Trip, second_trip: Trip, vehicle_info: dict return idle_consumption, -idle_consumption / capacity - def soc_at_departure_time(v_id, deps, departure_time, vehicle_data, stations, charge_curves, args): """ Get the possible SoC of the vehicle at a specified departure_time diff --git a/tests/test_consumption.py b/tests/test_consumption.py index bf6e3c26..229e631d 100644 --- a/tests/test_consumption.py +++ b/tests/test_consumption.py @@ -46,7 +46,6 @@ def test_calculate_idle_consumption(self, tmp_path): idle_consumption, idle_delta_soc = get_idle_consumption(first_trip, second_trip, v_info) assert idle_consumption == 0 - # make all rotations depb for r in schedule.rotations.values(): r.set_charging_type("depb") diff --git a/tests/test_rotation.py b/tests/test_rotation.py index c9012aa9..dcf19a08 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -1,6 +1,5 @@ import pytest -from simba.rotation import Rotation from tests.helpers import generate_basic_schedule @@ -29,4 +28,3 @@ def test_set_charging_type(): # of consumption is tested in test_consumption consumption_depb = s.calculate_rotation_consumption(rot) assert consumption_depb * 2 == pytest.approx(consumption_oppb) - diff --git a/tests/test_schedule.py b/tests/test_schedule.py index 2eee052b..57a5730b 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -694,7 +694,7 @@ def test_rotation_consumption_calc(self): r.trips = [] assert s.calculate_rotation_consumption(r) == 0 - some_rot = next(rot_iter) + some_rot = next(rot_iter) first_trip = some_rot.trips[0] del first_trip.rotation r.add_trip(vars(first_trip)) diff --git a/tests/test_simulate.py b/tests/test_simulate.py index 36b1a7ea..030c7ff4 100644 --- a/tests/test_simulate.py +++ b/tests/test_simulate.py @@ -169,7 +169,7 @@ def test_empty_report(self, tmp_path): def test_create_trips_in_report(self, tmp_path): # create_trips_in_report option: must generate valid input trips.csv args_dict = vars(self.get_args()) - update_dict={ + update_dict = { "mode": ["report"], "desired_soc_deps": 0, "ALLOW_NEGATIVE_SOC": True, @@ -180,13 +180,12 @@ def test_create_trips_in_report(self, tmp_path): } args_dict.update(update_dict) - # simulate base scenario, report generates new trips.csv in (tmp) output with warnings.catch_warnings(): warnings.simplefilter("ignore") simulate(Namespace(**args_dict)) # new simulation with generated trips.csv - args = vars(self.get_args()) + args_dict = vars(self.get_args()) args_dict["input_schedule"] = tmp_path / "report_1/trips.csv" simulate(Namespace(**(args_dict))) From 3aced374ab1d3b5d35b3ff145fb7238c0f7f81c3 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 10 Sep 2024 17:12:45 +0200 Subject: [PATCH 56/68] Update rst and add example to simba.cfg --- data/examples/simba.cfg | 7 +++++-- docs/source/simulation_parameters.rst | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/data/examples/simba.cfg b/data/examples/simba.cfg index da6287fa..2c22ec73 100644 --- a/data/examples/simba.cfg +++ b/data/examples/simba.cfg @@ -66,8 +66,11 @@ strategy_deps = balanced strategy_opps = greedy # additional options for depot or station strategies # refer to https://spice-ev.readthedocs.io/en/latest/simulating_with_spiceev.html#strategy-options -strategy_options_deps = {"CONCURRENCY": 1} -strategy_options_opps = {} +strategy_options_deps={"CONCURRENCY": 1} +strategy_options_opps={} +# Cost calculation strategy +cost_calculation_strategy_deps=balanced +cost_calculation_strategy_opps=greedy ##### Physical setup of environment ##### ### Parametrization of the physical setup ### diff --git a/docs/source/simulation_parameters.rst b/docs/source/simulation_parameters.rst index de8189c0..83e7b67b 100644 --- a/docs/source/simulation_parameters.rst +++ b/docs/source/simulation_parameters.rst @@ -80,6 +80,22 @@ The example (data/simba.cfg) contains parameter descriptions which are explained - false - Boolean - If activated, plots are displayed with every run of :ref:`report` mode + * - strategy_deps + - balanced + - SpiceEV Strategies (greedy, balanced, peak_shaving, peak_load_windows, balanced_market) + - Charging strategy used in depots. + * - strategy_opps + - greedy + - SpiceEV Strategies (greedy, balanced, peak_shaving, peak_load_windows, balanced_market) + - Charging strategy used in opportunity stations. + * - cost_calculation_strategy_deps + - strategy_deps value + - SpiceEV Strategies (greedy, balanced, peak_shaving, peak_load_windows, balanced_market) + - Strategy for cost calculation. + * - cost_calculation_strategy_opps + - strategy_opps value + - SpiceEV Strategies (greedy, balanced, peak_shaving, peak_load_windows, balanced_market) + - Strategy for cost calculation. * - preferred_charging_type - depb From 85db2461d35263f23c22bfc6fde767348c3aa0ac Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 10 Sep 2024 17:52:50 +0200 Subject: [PATCH 57/68] Fix formatting --- data/examples/simba.cfg | 9 +++++---- docs/source/simulation_parameters.rst | 5 ++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/data/examples/simba.cfg b/data/examples/simba.cfg index 2c22ec73..39404ec7 100644 --- a/data/examples/simba.cfg +++ b/data/examples/simba.cfg @@ -66,11 +66,12 @@ strategy_deps = balanced strategy_opps = greedy # additional options for depot or station strategies # refer to https://spice-ev.readthedocs.io/en/latest/simulating_with_spiceev.html#strategy-options -strategy_options_deps={"CONCURRENCY": 1} -strategy_options_opps={} +strategy_options_deps = {"CONCURRENCY": 1} +strategy_options_opps = {} + # Cost calculation strategy -cost_calculation_strategy_deps=balanced -cost_calculation_strategy_opps=greedy +cost_calculation_strategy_deps = balanced +cost_calculation_strategy_opps = greedy ##### Physical setup of environment ##### ### Parametrization of the physical setup ### diff --git a/docs/source/simulation_parameters.rst b/docs/source/simulation_parameters.rst index 83e7b67b..ee020018 100644 --- a/docs/source/simulation_parameters.rst +++ b/docs/source/simulation_parameters.rst @@ -91,12 +91,11 @@ The example (data/simba.cfg) contains parameter descriptions which are explained * - cost_calculation_strategy_deps - strategy_deps value - SpiceEV Strategies (greedy, balanced, peak_shaving, peak_load_windows, balanced_market) - - Strategy for cost calculation. + - Strategy for cost calculation at depots. * - cost_calculation_strategy_opps - strategy_opps value - SpiceEV Strategies (greedy, balanced, peak_shaving, peak_load_windows, balanced_market) - - Strategy for cost calculation. - + - Strategy for cost calculation at opportunity stations. * - preferred_charging_type - depb - depb, oppb From 4d50d79e815a8314d9d93559450c2d1c3159233c Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 10 Sep 2024 18:08:20 +0200 Subject: [PATCH 58/68] Raise Exception for PLW when timeseries does not contain window signal --- simba/costs.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/simba/costs.py b/simba/costs.py index 239ad0a9..ca64e331 100644 --- a/simba/costs.py +++ b/simba/costs.py @@ -329,6 +329,9 @@ def set_electricity_costs(self): # calculate costs for electricity try: + if cost_calculation_strategy == "peak_load_window": + if timeseries.get("window signal [-]") is None: + raise Exception("No peak load window signal provided for cost calculation") costs_electricity = calc_costs_spice_ev( strategy=cost_calculation_strategy, voltage_level=gc.voltage_level, From 6a47795f10d45751bf0faf99119df88c305c9e0d Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Wed, 11 Sep 2024 15:25:58 +0200 Subject: [PATCH 59/68] Implement review changes Fix grammar, capitalization, use mean for lol and temp, add data container tests --- simba/consumption.py | 1 - simba/data_container.py | 55 ++++++++++++++++++++++++------------ simba/schedule.py | 37 +++++++++++++----------- simba/util.py | 29 +++++++++++++++++++ tests/test_data_container.py | 18 ++++++++++++ tests/test_util.py | 33 +++++++++++++++++++++- 6 files changed, 137 insertions(+), 36 deletions(-) diff --git a/simba/consumption.py b/simba/consumption.py index 3a795863..fa09c949 100644 --- a/simba/consumption.py +++ b/simba/consumption.py @@ -44,7 +44,6 @@ def __call__(self, distance, vehicle_type, vehicle_info, temp, consumption_path = str(vehicle_info["mileage"]) # consumption_interpolation holds interpol functions of csv files which are called directly - # try to use the interpol function. If it does not exist yet its created in except case. consumption_lookup_name = self.get_consumption_lookup_name(consumption_path, vehicle_type) # This lookup includes the vehicle type. If the consumption data did not include vehicle diff --git a/simba/data_container.py b/simba/data_container.py index 1643342b..0b4c781b 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -4,7 +4,7 @@ import logging import datetime from pathlib import Path -from typing import Dict +from typing import Dict, Iterator import pandas as pd @@ -33,7 +33,7 @@ def __init__(self): self.trip_data: [dict] = [] def fill_with_args(self, args: argparse.Namespace) -> 'DataContainer': - """ Fill self with data from file_paths defined in args + """ Fill DataContainer with data from file_paths defined in args. :param args: Arguments containing paths for input_schedule, vehicle_types_path, electrified_stations_path, cost_parameters_path, outside_temperature_over_day_path, @@ -52,7 +52,7 @@ def fill_with_args(self, args: argparse.Namespace) -> 'DataContainer': ) def add_trip_data_from_csv(self, file_path: Path) -> 'DataContainer': - """ Add trip data from csv file to DataContainer + """ Add trip data from csv file to DataContainer. :param file_path: csv file path :return: self with trip data @@ -74,7 +74,7 @@ def add_trip_data_from_csv(self, file_path: Path) -> 'DataContainer': def add_station_geo_data(self, data: dict) -> None: """Add station_geo data to the data container. - Used when adding station_geo to a data container from any source + Used when adding station_geo to a data container from any source. :param data: data containing station_geo :type data: dict """ @@ -89,7 +89,8 @@ def fill_with_paths(self, cost_parameters_path=None, station_data_path=None, ): - """ Fill self with data from file_paths + """ Fill DataContainer with data from file_paths. + :param trips_file_path: csv path to trips :param vehicle_types_path: json file path to vehicle_types :param electrified_stations_path: json file path to electrified stations @@ -127,8 +128,14 @@ def fill_with_paths(self, return self def add_station_geo_data_from_csv(self, file_path: Path) -> 'DataContainer': - # find the temperature and elevation of the stations by reading the .csv file. - # this data is stored in the schedule and passed to the trips, which use the information + """ Fill DataContainer with geo data from file_paths. + + :param file_path: csv path to geodata + :param file_path: Path + :return: self + """ + # Find the temperature and elevation of the stations by reading the .csv file. + # This data is stored in the schedule and passed to the trips, which use the information # for consumption calculation. Missing station data is handled with default values. self.station_geo_data = dict() try: @@ -148,16 +155,16 @@ def add_station_geo_data_from_csv(self, file_path: Path) -> 'DataContainer': level=100) raise except ValueError: - line_num += 2 - logging.log(msg=f"Can't parse numeric data in line {line_num} from file {file_path}.", - level=100) + logging.log( + msg=f"Can't parse numeric data in line {line_num + 2} from file {file_path}.", + level=100) raise return self def add_level_of_loading_data(self, data: dict) -> 'DataContainer': """Add level_of_loading data to the data container. - Used when adding level_of_loading to a data container from any source + Used when adding level_of_loading to a data container from any source. :param data: data containing hour and level_of_loading :type data: dict :return: DataContainer containing level of loading data @@ -166,6 +173,13 @@ def add_level_of_loading_data(self, data: dict) -> 'DataContainer': return self def add_level_of_loading_data_from_csv(self, file_path: Path) -> 'DataContainer': + """ Fill DataContainer with level of loading data from file_paths. + + :param file_path: csv path to level of loading data + :param file_path: Path + :return: self + """ + index = "hour" column = "level_of_loading" level_of_loading_data_dict = util.get_dict_from_csv(column, file_path, index) @@ -263,6 +277,7 @@ def add_vehicle_types_from_json(self, file_path: Path): @staticmethod def get_json_from_file(file_path: Path, data_type: str) -> any: """ Get json data from a file_path and raise verbose error if it fails. + :raises FileNotFoundError: if file does not exist :param file_path: file path :param data_type: data type used for error description @@ -276,8 +291,12 @@ def get_json_from_file(file_path: Path, data_type: str) -> any: "does not exist. Exiting...") def add_consumption_data_from_vehicle_type_linked_files(self): - """ Add mileage data from files linked in the vehicle_types to the container. + """ Add consumption data to the data container. + Consumption data will be used by the Consumption instance. + Vehicle types are expected to link to a file with their respective consumption data. + Example: if Vehicle type '12m_opp' has a mileage of 'consumption_12m.csv', + then the data_name must be 'consumption_12m.csv'. :return: DataContainer containing consumption data """ assert self.vehicle_types_data, "No vehicle_type data in the data_container" @@ -292,12 +311,12 @@ def add_consumption_data_from_vehicle_type_linked_files(self): return self def add_consumption_data(self, data_name, df: pd.DataFrame) -> 'DataContainer': - """Add consumption data to the data container. Consumption data will be used by the - Consumption instance + """Add consumption data to the data container. - data_name must be equal to the mileage attribute in vehicle_types. E.g. Vehicle type + Consumption data will be used by the Consumption instance. + __data_name__ must be equal to the mileage attribute in vehicle_types. E.g. Vehicle type '12m_opp' has a mileage of 'consumption_12m.csv' -> data_name ='consumption_12m.csv' - :param data_name: name of the data, linked with vehicle_type + :param data_name: name of the data, linked to vehicle_type :type data_name: str :param df: dataframe with consumption data and various expected columns :type df: pd.DataFrame @@ -312,8 +331,8 @@ def add_consumption_data(self, data_name, df: pd.DataFrame) -> 'DataContainer': return self -def get_values_from_nested_key(key, data: dict) -> list: - """Get all the values of the specified key in a nested dict +def get_values_from_nested_key(key, data: dict) -> Iterator[any]: + """Get all the values of the specified key in a nested dict. :param key: key to find :param data: data to search through diff --git a/simba/schedule.py b/simba/schedule.py index 2b05f5f0..0132ff78 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -116,15 +116,18 @@ def from_datacontainer(cls, data: DataContainer, args): trip["height_difference"] = schedule.get_height_difference( trip["departure_name"], trip["arrival_name"]) + if trip["level_of_loading"] is None: - trip["level_of_loading"] = data.level_of_loading_data.get(hour) + trip["level_of_loading"] = util.get_mean_from_hourly_dict( + data.level_of_loading_data, trip["departure_time"], trip["arrival_time"]) else: if not 0 <= trip["level_of_loading"] <= 1: logging.warning("Level of loading is out of range [0,1] and will be clipped.") trip["level_of_loading"] = min(1, max(0, trip["level_of_loading"])) if trip["temperature"] is None: - trip["temperature"] = data.temperature_data.get(hour) + trip["temperature"] = util.get_mean_from_hourly_dict( + data.temperature_data, trip["departure_time"], trip["arrival_time"]) if rotation_id not in schedule.rotations.keys(): schedule.rotations.update({ @@ -142,7 +145,7 @@ def from_datacontainer(cls, data: DataContainer, args): if vars(args).get("check_rotation_consistency"): # check rotation expectations inconsistent_rotations = cls.check_consistency(schedule) - if inconsistent_rotations: + if inconsistent_rotations and args.output_directory is not None: # write errors to file filepath = args.output_directory / "inconsistent_rotations.csv" with open(filepath, "w", encoding='utf-8') as f: @@ -355,10 +358,10 @@ def assign_vehicles_custom(self, vehicle_assigns: Iterable[dict]): :type vehicle_assigns: Iterable[dict] :raises KeyError: If not every rotation has a vehicle assigned to it """ - eflips_rot_dict = {d["rot"]: {"v_id": d["v_id"], "soc": d["soc"]} for d in vehicle_assigns} + rotation_dict = {d["rot"]: {"v_id": d["v_id"], "soc": d["soc"]} for d in vehicle_assigns} unique_vids = {d["v_id"] for d in vehicle_assigns} vehicle_socs = {v_id: dict() for v_id in unique_vids} - eflips_vid_dict = {v_id: sorted([d["rot"] for d in vehicle_assigns + vid_dict = {v_id: sorted([d["rot"] for d in vehicle_assigns if d["v_id"] == v_id], key=lambda r_id: self.rotations[r_id].departure_time) for v_id in unique_vids} @@ -378,21 +381,21 @@ def assign_vehicles_custom(self, vehicle_assigns: Iterable[dict]): rotations = sorted(self.rotations.values(), key=lambda rot: rot.departure_time) for rot in rotations: try: - v_id = eflips_rot_dict[rot.id]["v_id"] + v_id = rotation_dict[rot.id]["v_id"] except KeyError as exc: - raise KeyError(f"SoC-data does not include the rotation with the id: {rot.id}. " - "Externally generated vehicles assignments need to include all " + raise Exception(f"SoC-data does not include the rotation with the id: {rot.id}. " + "Externally generated vehicle assignments need to include all " "rotations") from exc rot.vehicle_id = v_id - index = eflips_vid_dict[v_id].index(rot.id) + index = vid_dict[v_id].index(rot.id) # if this rotation is not the first rotation of the vehicle, find the previous trip if index != 0: - prev_rot_id = eflips_vid_dict[v_id][index - 1] + prev_rot_id = vid_dict[v_id][index - 1] trip = self.rotations[prev_rot_id].trips[-1] else: # if the rotation has no previous trip, trip is set as None trip = None - vehicle_socs[v_id][trip] = eflips_rot_dict[rot.id]["soc"] + vehicle_socs[v_id][trip] = rotation_dict[rot.id]["soc"] self.soc_dispatcher.vehicle_socs = vehicle_socs def init_soc_dispatcher(self, args): @@ -587,13 +590,13 @@ def calculate_rotation_consumption(self, rotation: Rotation): :return: Consumption of rotation [kWh] :rtype: float """ + rotation.consumption = 0 + if len(rotation.trips) == 0: - rotation.consumption = 0 return rotation.consumption # get the specific idle consumption of this vehicle type in kWh/h v_info = self.vehicle_types[rotation.vehicle_type][rotation.charging_type] - rotation_consumption = 0 # make sure the trips are sorted, so the next trip can be determined rotation.trips = list(sorted(rotation.trips, key=lambda trip: trip.arrival_time)) trip = rotation.trips[0] @@ -608,14 +611,13 @@ def calculate_rotation_consumption(self, rotation: Rotation): trip.consumption = driving_consumption + idle_consumption trip.delta_soc = driving_delta_soc + idle_delta_soc - rotation_consumption += driving_consumption + idle_consumption + rotation.consumption += driving_consumption + idle_consumption trip = next_trip # last trip of the rotation has no idle consumption trip.consumption = self.calculate_trip_consumption(trip) - rotation_consumption += trip.consumption + rotation.consumption += trip.consumption - rotation.consumption = rotation_consumption return rotation.consumption def calculate_trip_consumption(self, trip: Trip): @@ -1020,6 +1022,9 @@ def generate_scenario(self, args): try: departure_station_type = self.stations[gc_name]["type"] except KeyError: + # station was not found in electrified stations. + # Initialization uses default of "deps", + # since usually vehicles start from depots. departure_station_type = "deps" vehicles[vehicle_id] = { "connected_charging_station": None, diff --git a/simba/util.py b/simba/util.py index 50f0f740..0e4ef838 100644 --- a/simba/util.py +++ b/simba/util.py @@ -3,6 +3,7 @@ import json import logging import subprocess +from datetime import datetime, timedelta from spice_ev.strategy import STRATEGIES from spice_ev.util import set_options_from_config @@ -42,6 +43,34 @@ def uncomment_json_file(f, char='//'): return json.loads(uncommented_data) +def get_mean_from_hourly_dict(hourly_dict: dict, start: datetime, end: datetime) -> float: + """ Get the mean value from hourly data. + + Uses the daterange from start until end to calculate the minute resolved mean value of + a dictionary with hourly data. + :param hourly_dict: + :param start: + :param end: + :return: + """ + # Special case for shared hour of same day. + divider = end - start + if divider < timedelta(hours=1) and start.hour == end.hour: + return hourly_dict.get(start.hour) + + timestep = timedelta(hours=1) + # Proportionally add the start value until the next hour + value = hourly_dict.get(start.hour) * (60 - start.minute) + start = (start + timestep).replace(minute=0) + for dt in daterange(start, end, timestep): + # proportionally apply value according to minutes inside the current hour. + duration = min((end - dt).total_seconds() / 60, 60) + value += (hourly_dict.get(dt.hour) * duration) + # divide by total minutes to get mean value + value /= (divider.total_seconds() / 60) + return value + + def get_csv_delim(path, other_delims=set()): """ Get the delimiter of a character separated file. Checks the file for ",", "tabulator" and ";" as well as optional other characters diff --git a/tests/test_data_container.py b/tests/test_data_container.py index e69de29b..d4f94873 100644 --- a/tests/test_data_container.py +++ b/tests/test_data_container.py @@ -0,0 +1,18 @@ +import sys + +import simba.data_container +from simba import util +from tests.conftest import example_root + +class TestDataContainer: + def test_get_values_from_nested_key(self): + nested_dict = {"foo": {"bar": "baz1"}, "bob": {"bar": "baz2"}} + gen = simba.data_container.get_values_from_nested_key("bar", nested_dict) + assert next(gen) == "baz1" + assert next(gen) == "baz2" + + def test_data_container(self): + data_container = simba.data_container.DataContainer() + sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] + args = util.get_args() + data_container.fill_with_args(args) \ No newline at end of file diff --git a/tests/test_util.py b/tests/test_util.py index b5c565e5..f50f16be 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta import json from simba import util from tests.test_schedule import BasicSchedule @@ -74,3 +74,34 @@ def test_get_buffer_time(self): trip.arrival_time = datetime(year=2023, month=1, day=2, hour=6, second=1) assert util.get_buffer_time(trip, default=buffer_time) == 1 + + def test_get_mean_from_hourly_dict(self): + # Dict with values 0-23 + hourly_dict = {x: x for x in range(0, 24)} + start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + end = (datetime.now() + timedelta(days=1)).replace(hour=0, minute=0, second=0, + microsecond=0) + assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 11.5 + end = (start + timedelta(hours=1)) + assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 0 + end = (start + timedelta(hours=2)) + assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 0.5 + start = start.replace(hour=0, minute=59, second=0, microsecond=0) + end = start.replace(hour=1, minute=1, second=0, microsecond=0) + assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 0.5 + start = start.replace(hour=0, minute=59, second=0, microsecond=0) + end = start.replace(hour=1, minute=2, second=0, microsecond=0) + assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 2 / 3 + start = start.replace(hour=0, minute=59, second=0, microsecond=0) + end = start.replace(hour=1, minute=59, second=0, microsecond=0) + assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 59 / 60 + + start = start.replace(hour=0, minute=0, second=0, microsecond=0) + end = start.replace(hour=0, minute=10, second=0, microsecond=0) + assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 0 + start = start.replace(hour=1, minute=0, second=0, microsecond=0) + end = start.replace(hour=1, minute=0, second=0, microsecond=0) + assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 1 + start = start.replace(hour=1, minute=0, second=0, microsecond=0) + end = start.replace(hour=1, minute=1, second=0, microsecond=0) + assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 1 From 8b8504a691cbf7c3b541e2b75f544ad5ea235715 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Wed, 11 Sep 2024 17:29:04 +0200 Subject: [PATCH 60/68] Make flake8 happy --- simba/data_container.py | 5 ++++- simba/schedule.py | 18 +++++++----------- simba/util.py | 12 ++++++++---- tests/test_data_container.py | 3 ++- tests/test_soc_dispatcher.py | 2 +- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/simba/data_container.py b/simba/data_container.py index 0b4c781b..f41fd1d9 100644 --- a/simba/data_container.py +++ b/simba/data_container.py @@ -130,8 +130,11 @@ def fill_with_paths(self, def add_station_geo_data_from_csv(self, file_path: Path) -> 'DataContainer': """ Fill DataContainer with geo data from file_paths. - :param file_path: csv path to geodata + :param file_path: csv path to geodata+ :param file_path: Path + :raises FileNotFoundError: if file does not exist + :raises KeyError: if file does not contain the required keys + :raises ValueError: if values are not numeric :return: self """ # Find the temperature and elevation of the stations by reading the .csv file. diff --git a/simba/schedule.py b/simba/schedule.py index 0132ff78..c93bc3c4 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -108,15 +108,10 @@ def from_datacontainer(cls, data: DataContainer, args): for trip in data.trip_data: rotation_id = trip['rotation_id'] - # get average hour of trip and parse to string, since tabular data has strings as keys - hour = (trip["departure_time"] + - (trip["arrival_time"] - trip["departure_time"]) / 2).hour - # Get height difference from station_data trip["height_difference"] = schedule.get_height_difference( trip["departure_name"], trip["arrival_name"]) - if trip["level_of_loading"] is None: trip["level_of_loading"] = util.get_mean_from_hourly_dict( data.level_of_loading_data, trip["departure_time"], trip["arrival_time"]) @@ -262,6 +257,7 @@ def set_charging_type(self, ct, rotation_ids=None): def assign_vehicles(self, args): """ Assign vehicles using the strategy given in the arguments + :param args: Arguments with attribute assign_strategy :type args: Namespace :raises NotImplementedError: if args.assign_strategy has a no allowed value @@ -356,15 +352,15 @@ def assign_vehicles_custom(self, vehicle_assigns: Iterable[dict]): :param vehicle_assigns: Iterable of dict with keys rotation_id, vehicle_id and start_soc for each rotation :type vehicle_assigns: Iterable[dict] - :raises KeyError: If not every rotation has a vehicle assigned to it + :raises Exception: If not every rotation has a vehicle assigned to it """ rotation_dict = {d["rot"]: {"v_id": d["v_id"], "soc": d["soc"]} for d in vehicle_assigns} unique_vids = {d["v_id"] for d in vehicle_assigns} vehicle_socs = {v_id: dict() for v_id in unique_vids} vid_dict = {v_id: sorted([d["rot"] for d in vehicle_assigns - if d["v_id"] == v_id], - key=lambda r_id: self.rotations[r_id].departure_time) - for v_id in unique_vids} + if d["v_id"] == v_id], + key=lambda r_id: self.rotations[r_id].departure_time) + for v_id in unique_vids} # Calculate vehicle counts # count number of vehicles per type @@ -384,8 +380,8 @@ def assign_vehicles_custom(self, vehicle_assigns: Iterable[dict]): v_id = rotation_dict[rot.id]["v_id"] except KeyError as exc: raise Exception(f"SoC-data does not include the rotation with the id: {rot.id}. " - "Externally generated vehicle assignments need to include all " - "rotations") from exc + "Externally generated vehicle assignments need to include all " + "rotations") from exc rot.vehicle_id = v_id index = vid_dict[v_id].index(rot.id) # if this rotation is not the first rotation of the vehicle, find the previous trip diff --git a/simba/util.py b/simba/util.py index 0e4ef838..06cf0bb4 100644 --- a/simba/util.py +++ b/simba/util.py @@ -48,10 +48,14 @@ def get_mean_from_hourly_dict(hourly_dict: dict, start: datetime, end: datetime) Uses the daterange from start until end to calculate the minute resolved mean value of a dictionary with hourly data. - :param hourly_dict: - :param start: - :param end: - :return: + :param hourly_dict: dictionary with hourly keys and data + :type hourly_dict: dict + :param start: start of the range for interpolation + :type start: datetime + :param end: end of the range for interpolation + :type end: datetime + :return: mean value + :rtype: float """ # Special case for shared hour of same day. divider = end - start diff --git a/tests/test_data_container.py b/tests/test_data_container.py index d4f94873..d9709fcf 100644 --- a/tests/test_data_container.py +++ b/tests/test_data_container.py @@ -4,6 +4,7 @@ from simba import util from tests.conftest import example_root + class TestDataContainer: def test_get_values_from_nested_key(self): nested_dict = {"foo": {"bar": "baz1"}, "bob": {"bar": "baz2"}} @@ -15,4 +16,4 @@ def test_data_container(self): data_container = simba.data_container.DataContainer() sys.argv = ["foo", "--config", str(example_root / "simba.cfg")] args = util.get_args() - data_container.fill_with_args(args) \ No newline at end of file + data_container.fill_with_args(args) diff --git a/tests/test_soc_dispatcher.py b/tests/test_soc_dispatcher.py index 08275596..4c19c72f 100644 --- a/tests/test_soc_dispatcher.py +++ b/tests/test_soc_dispatcher.py @@ -114,5 +114,5 @@ def test_basic_missing_rotation(self, custom_vehicle_assignment): del custom_vehicle_assignment[-1] # if data for a rotation is missing an error containing the rotation id should be raised - with pytest.raises(KeyError, match=missing_rot_id): + with pytest.raises(Exception, match=missing_rot_id): sched.assign_vehicles_custom(custom_vehicle_assignment) From 794c9691807f978d3f3cfcfe68aa327e2e65385f Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 12 Sep 2024 09:59:39 +0200 Subject: [PATCH 61/68] Refactor get_idle_consumption Add warnings. Enforce positive idle consumption --- simba/schedule.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/simba/schedule.py b/simba/schedule.py index c93bc3c4..91b530ed 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -2,6 +2,7 @@ import datetime import json import logging +from math import ceil from pathlib import Path import random import warnings @@ -1097,7 +1098,7 @@ def generate_scenario(self, args): "scenario": { "start_time": start_simulation.isoformat(), "interval": interval.days * 24 * 60 + interval.seconds // 60, - "n_intervals": int(abs((start_simulation - stop_simulation) // interval)) + "n_intervals": ceil((stop_simulation-start_simulation) / interval) }, "components": { "vehicle_types": vehicle_types_spiceev, @@ -1402,12 +1403,16 @@ def get_idle_consumption(first_trip: Trip, second_trip: Trip, vehicle_info: dict """ capacity = vehicle_info["capacity"] idle_cons_spec = vehicle_info.get("idle_consumption", 0) - if idle_cons_spec == 0: - return 0, 0 - - break_duration = second_trip.departure_time - first_trip.arrival_time - assert break_duration.total_seconds() >= 0 - idle_consumption = break_duration.total_seconds() / 3600 * idle_cons_spec + if idle_cons_spec < 0: + logging.warning("Specific idle consumption is negative. This would charge the vehicle. " + "Idle consumption is set to Zero instead.") + + break_duration_s = (second_trip.departure_time - first_trip.arrival_time).total_seconds() + if break_duration_s < 0: + logging.warning("Break duration is negative. This would charge the vehicle. " + "Idle consumption is set to Zero instead") + # Do not allow negative idle consumption, i.e., charging the vehicle. + idle_consumption = max(break_duration_s / 3600 * idle_cons_spec, 0) return idle_consumption, -idle_consumption / capacity From 58fee9b46463cf14c1bf7cf8091efdd025789b41 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Thu, 12 Sep 2024 10:02:10 +0200 Subject: [PATCH 62/68] Fix comments capitalization --- simba/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/simba/util.py b/simba/util.py index 06cf0bb4..41c93f29 100644 --- a/simba/util.py +++ b/simba/util.py @@ -57,13 +57,13 @@ def get_mean_from_hourly_dict(hourly_dict: dict, start: datetime, end: datetime) :return: mean value :rtype: float """ - # Special case for shared hour of same day. + # special case for shared hour of the same day. divider = end - start if divider < timedelta(hours=1) and start.hour == end.hour: return hourly_dict.get(start.hour) timestep = timedelta(hours=1) - # Proportionally add the start value until the next hour + # proportionally add the start value until the next hour value = hourly_dict.get(start.hour) * (60 - start.minute) start = (start + timestep).replace(minute=0) for dt in daterange(start, end, timestep): From 590c3570d72db92d039b4dfe41c2772089432f74 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Fri, 13 Sep 2024 09:51:45 +0200 Subject: [PATCH 63/68] Make get_mean_from_hourly robust against seconds and microseconds Fix some punctuation. Clean up --- simba/schedule.py | 3 ++- simba/util.py | 24 ++++++++++++------------ tests/test_util.py | 32 ++++++++++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/simba/schedule.py b/simba/schedule.py index 91b530ed..c469a054 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -581,9 +581,10 @@ def calculate_consumption(self): return self.consumption def calculate_rotation_consumption(self, rotation: Rotation): - rotation.consumption = 0 """ Calculate consumption of this rotation and all its trips. + :param: rotation: Rotation to calculate consumption for + :type rotation: Rotation :return: Consumption of rotation [kWh] :rtype: float """ diff --git a/simba/util.py b/simba/util.py index 41c93f29..230e32a3 100644 --- a/simba/util.py +++ b/simba/util.py @@ -46,8 +46,7 @@ def uncomment_json_file(f, char='//'): def get_mean_from_hourly_dict(hourly_dict: dict, start: datetime, end: datetime) -> float: """ Get the mean value from hourly data. - Uses the daterange from start until end to calculate the minute resolved mean value of - a dictionary with hourly data. + Use daterange from start to end for calculating a mean value by looking up hourly data. :param hourly_dict: dictionary with hourly keys and data :type hourly_dict: dict :param start: start of the range for interpolation @@ -57,21 +56,22 @@ def get_mean_from_hourly_dict(hourly_dict: dict, start: datetime, end: datetime) :return: mean value :rtype: float """ - # special case for shared hour of the same day. - divider = end - start - if divider < timedelta(hours=1) and start.hour == end.hour: + total_duration = end - start + # special case for shared hour of the same day + if total_duration < timedelta(hours=1) and start.hour == end.hour: return hourly_dict.get(start.hour) timestep = timedelta(hours=1) # proportionally add the start value until the next hour - value = hourly_dict.get(start.hour) * (60 - start.minute) - start = (start + timestep).replace(minute=0) + next_full_hour = start.replace(hour=start.hour + 1, minute=0, second=0, microsecond=0) + value = hourly_dict.get(start.hour) * (next_full_hour - start).total_seconds() + start = next_full_hour for dt in daterange(start, end, timestep): - # proportionally apply value according to minutes inside the current hour. - duration = min((end - dt).total_seconds() / 60, 60) - value += (hourly_dict.get(dt.hour) * duration) - # divide by total minutes to get mean value - value /= (divider.total_seconds() / 60) + # proportionally apply value according to seconds inside the current hour. + step_duration = min((end - dt).total_seconds(), 3600) + value += (hourly_dict.get(dt.hour) * step_duration) + # divide by total seconds to get mean value + value /= (total_duration.total_seconds()) return value diff --git a/tests/test_util.py b/tests/test_util.py index f50f16be..0b716f66 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -78,30 +78,58 @@ def test_get_buffer_time(self): def test_get_mean_from_hourly_dict(self): # Dict with values 0-23 hourly_dict = {x: x for x in range(0, 24)} + + # Range off a whole day should give 23/2 = 11.5 start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - end = (datetime.now() + timedelta(days=1)).replace(hour=0, minute=0, second=0, - microsecond=0) + end = (start + timedelta(days=1)) assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 11.5 + + # First hour of the day has value of 0 end = (start + timedelta(hours=1)) assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 0 + + # Second hour of the day has value of 1. Mean of [0,1] is 0.5 end = (start + timedelta(hours=2)) assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 0.5 + + # 1 minute of the first hour and second hour with values of 0 and 1 should result in 0.5 start = start.replace(hour=0, minute=59, second=0, microsecond=0) end = start.replace(hour=1, minute=1, second=0, microsecond=0) assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 0.5 + + # 1 minute of the first hour and 2 minutes of the second hour with values of 0 and 1 + # should result in (0+1+1)/3 = 2/3 start = start.replace(hour=0, minute=59, second=0, microsecond=0) end = start.replace(hour=1, minute=2, second=0, microsecond=0) assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 2 / 3 + + # 1 minute of the first hour and 59 minutes of the second hour with values of 0 and 1 + # should result in (1*0+59*1)/60 = 59/60 start = start.replace(hour=0, minute=59, second=0, microsecond=0) end = start.replace(hour=1, minute=59, second=0, microsecond=0) assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 59 / 60 + # Daterange which covers part of the first hour should result in the value of the first hour start = start.replace(hour=0, minute=0, second=0, microsecond=0) end = start.replace(hour=0, minute=10, second=0, microsecond=0) assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 0 + + # No duration should work. 1 hour results in the lookup 1. start = start.replace(hour=1, minute=0, second=0, microsecond=0) end = start.replace(hour=1, minute=0, second=0, microsecond=0) assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 1 + + # Check lookup of second hour. Make sure 1 is returned. start = start.replace(hour=1, minute=0, second=0, microsecond=0) end = start.replace(hour=1, minute=1, second=0, microsecond=0) assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 1 + + # Check if seconds are properly handled. 1s of 0 and 2s of 1 -> 2/3 + start = start.replace(hour=0, minute=59, second=59, microsecond=0) + end = start.replace(hour=1, minute=0, second=2, microsecond=0) + assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 2 / 3 + + # Check if seconds are properly handled. 1ms of 0 and 2ms of 1 -> 2/3 + start = start.replace(hour=0, minute=59, second=59, microsecond=999) + end = start.replace(hour=1, minute=0, second=0, microsecond=2) + assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 2 / 3 From 98160945f9a32179ee0c25afceb833f5144085fa Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Fri, 13 Sep 2024 10:04:29 +0200 Subject: [PATCH 64/68] Fix microseconds Test confused microseconds with milliseconds. --- simba/util.py | 2 +- tests/test_util.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/simba/util.py b/simba/util.py index 230e32a3..af956a18 100644 --- a/simba/util.py +++ b/simba/util.py @@ -63,7 +63,7 @@ def get_mean_from_hourly_dict(hourly_dict: dict, start: datetime, end: datetime) timestep = timedelta(hours=1) # proportionally add the start value until the next hour - next_full_hour = start.replace(hour=start.hour + 1, minute=0, second=0, microsecond=0) + next_full_hour = (start+timestep).replace(hour=0, minute=0, second=0, microsecond=0) value = hourly_dict.get(start.hour) * (next_full_hour - start).total_seconds() start = next_full_hour for dt in daterange(start, end, timestep): diff --git a/tests/test_util.py b/tests/test_util.py index 0b716f66..8fa82664 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -130,6 +130,6 @@ def test_get_mean_from_hourly_dict(self): assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 2 / 3 # Check if seconds are properly handled. 1ms of 0 and 2ms of 1 -> 2/3 - start = start.replace(hour=0, minute=59, second=59, microsecond=999) + start = start.replace(hour=0, minute=59, second=59, microsecond=int(1e6-1)) end = start.replace(hour=1, minute=0, second=0, microsecond=2) assert util.get_mean_from_hourly_dict(hourly_dict, start, end) == 2 / 3 From af9c5d9d1bb5ff02ded98fc022bc2f0961107689 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 17 Sep 2024 13:04:14 +0200 Subject: [PATCH 65/68] Fix path lookup --- tests/test_cost_calculation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cost_calculation.py b/tests/test_cost_calculation.py index 39fb36d1..110b0b2c 100644 --- a/tests/test_cost_calculation.py +++ b/tests/test_cost_calculation.py @@ -6,7 +6,7 @@ class TestCostCalculation: def test_cost_calculation(self): schedule, scenario, args = BasicSchedule().basic_run() - file = args.cost_parameters_file + file = args.cost_parameters_path with open(file, "r") as file: cost_params = uncomment_json_file(file) From c4cebcdc3044ef8c5c870ae914abca87a78f0305 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 17 Sep 2024 13:07:09 +0200 Subject: [PATCH 66/68] Rename strategy to cost_strategy --- simba/costs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/simba/costs.py b/simba/costs.py index 420f4925..f04cd853 100644 --- a/simba/costs.py +++ b/simba/costs.py @@ -324,8 +324,8 @@ def set_electricity_costs(self): # If no value is set, use the same strategy as the charging strategy default_cost_strategy = vars(self.args)["strategy_" + station.get("type")] - strategy_name = "cost_calculation_strategy_" + station.get("type") - cost_calculation_strategy = vars(self.args).get(strategy_name) or default_cost_strategy + cost_strategy_name = "cost_calculation_strategy_" + station.get("type") + cost_calculation_strategy = vars(self.args).get(cost_strategy_name) or default_cost_strategy # calculate costs for electricity try: From 61dd12b12aad4de09dd7b2c1aa1c2d62451c82c3 Mon Sep 17 00:00:00 2001 From: "paul.scheer" Date: Tue, 17 Sep 2024 13:07:52 +0200 Subject: [PATCH 67/68] Make flake8 happy --- simba/costs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/simba/costs.py b/simba/costs.py index f04cd853..5d1a998d 100644 --- a/simba/costs.py +++ b/simba/costs.py @@ -325,7 +325,8 @@ def set_electricity_costs(self): default_cost_strategy = vars(self.args)["strategy_" + station.get("type")] cost_strategy_name = "cost_calculation_strategy_" + station.get("type") - cost_calculation_strategy = vars(self.args).get(cost_strategy_name) or default_cost_strategy + cost_calculation_strategy = (vars(self.args).get(cost_strategy_name) + or default_cost_strategy) # calculate costs for electricity try: From 4cfecc93cd8c52b04c4a42c193cbaec6377d85d2 Mon Sep 17 00:00:00 2001 From: "stefan.schirmeister" Date: Wed, 18 Sep 2024 13:52:49 +0200 Subject: [PATCH 68/68] temperature/lol: add data check --- simba/schedule.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/simba/schedule.py b/simba/schedule.py index c469a054..341e3ba3 100644 --- a/simba/schedule.py +++ b/simba/schedule.py @@ -114,6 +114,7 @@ def from_datacontainer(cls, data: DataContainer, args): trip["departure_name"], trip["arrival_name"]) if trip["level_of_loading"] is None: + assert len(data.level_of_loading_data) == 24, "Need 24 entries in level of loading" trip["level_of_loading"] = util.get_mean_from_hourly_dict( data.level_of_loading_data, trip["departure_time"], trip["arrival_time"]) else: @@ -122,6 +123,7 @@ def from_datacontainer(cls, data: DataContainer, args): trip["level_of_loading"] = min(1, max(0, trip["level_of_loading"])) if trip["temperature"] is None: + assert len(data.temperature_data) == 24, "Need 24 entries in temperature data" trip["temperature"] = util.get_mean_from_hourly_dict( data.temperature_data, trip["departure_time"], trip["arrival_time"])