eflips-depot 4.9.1__tar.gz → 4.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of eflips-depot might be problematic. Click here for more details.

Files changed (42) hide show
  1. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/PKG-INFO +1 -1
  2. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/api/__init__.py +170 -38
  3. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/api/private/consumption.py +273 -1
  4. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/api/private/depot.py +2 -1
  5. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/api/private/results_to_database.py +1 -1
  6. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/api/private/util.py +34 -0
  7. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/depot.py +4 -0
  8. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/pyproject.toml +1 -1
  9. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/LICENSE.md +0 -0
  10. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/README.md +0 -0
  11. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/__init__.py +0 -0
  12. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/api/defaults/default_settings.json +0 -0
  13. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/api/private/__init__.py +0 -0
  14. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/configuration.py +0 -0
  15. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/evaluation.py +0 -0
  16. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/filters.py +0 -0
  17. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/input_epex_power_price.py +0 -0
  18. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/__init__.py +0 -0
  19. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/doc/__init__.py +0 -0
  20. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/doc/direct_details.pdf +0 -0
  21. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/evaluation.py +0 -0
  22. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/opt_tools/__init__.py +0 -0
  23. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/opt_tools/crossover.py +0 -0
  24. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/opt_tools/fitness_c_urfd.py +0 -0
  25. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/opt_tools/fitness_util.py +0 -0
  26. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/opt_tools/init.py +0 -0
  27. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/opt_tools/mutation.py +0 -0
  28. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/optimize_c_urfd.py +0 -0
  29. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/packing.py +0 -0
  30. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/settings.py +0 -0
  31. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/template_creation.py +0 -0
  32. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/layout_opt/util.py +0 -0
  33. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/plots.py +0 -0
  34. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/processes.py +0 -0
  35. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/rating.py +0 -0
  36. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/resources.py +0 -0
  37. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/settings_config.py +0 -0
  38. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/simple_vehicle.py +0 -0
  39. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/simulation.py +0 -0
  40. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/smart_charging.py +0 -0
  41. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/standalone.py +0 -0
  42. {eflips_depot-4.9.1 → eflips_depot-4.11.0}/eflips/depot/validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: eflips-depot
3
- Version: 4.9.1
3
+ Version: 4.11.0
4
4
  Summary: Depot Simulation for eFLIPS
5
5
  License: AGPL-3.0-or-later
6
6
  Author: Enrico Lauth
@@ -49,6 +49,9 @@ from eflips.model import (
49
49
  AreaType,
50
50
  ChargeType,
51
51
  Route,
52
+ ConsistencyWarning,
53
+ Station,
54
+ ConsumptionLut,
52
55
  )
53
56
  from sqlalchemy.orm import Session
54
57
 
@@ -57,16 +60,19 @@ from eflips.depot import (
57
60
  DepotEvaluation,
58
61
  SimulationHost,
59
62
  )
63
+ from eflips.depot.api.private.consumption import ConsumptionResult
60
64
  from eflips.depot.api.private.consumption import (
61
65
  initialize_vehicle,
62
66
  add_initial_standby_event,
63
67
  attempt_opportunity_charging_event,
68
+ extract_trip_information,
64
69
  )
65
70
  from eflips.depot.api.private.depot import (
66
71
  delete_depots,
67
72
  depot_to_template,
68
73
  group_rotations_by_start_end_stop,
69
74
  generate_depot,
75
+ depot_smallest_possible_size,
70
76
  )
71
77
  from eflips.depot.api.private.results_to_database import (
72
78
  get_finished_schedules_per_vehicle,
@@ -113,37 +119,33 @@ class SmartChargingStrategy(Enum):
113
119
  """
114
120
 
115
121
 
116
- @dataclass
117
- class ConsumptionResult:
122
+ def generate_consumption_result(scenario):
118
123
  """
119
- A dataclass that stores the results of a charging simulation for a single trip.
120
-
121
- This class holds both the total change in battery State of Charge (SoC) over the trip
122
- as well as an optional timeseries of timestamps and incremental SoC changes. When
123
- an entry exists for a given trip in ``consumption_result``, the simulation will use
124
- these precomputed values instead of recalculating the SoC changes from the vehicle
125
- distance and consumption.
126
-
127
- :param delta_soc_total:
128
- The total change in the vehicle's State of Charge over the trip, typically
129
- negative if the vehicle is consuming energy (e.g., -0.15 means the SoC
130
- dropped by 15%).
131
-
132
- :param timestamps:
133
- A list of timestamps (e.g., arrival times at stops) that mark the times
134
- associated with the SoC changes. The number of timestamps must match the
135
- number of entries in ``delta_soc``.
136
-
137
- :param delta_soc:
138
- A list of cumulative SoC changes corresponding to the ``timestamps``.
139
- For example, if ``delta_soc[i] = -0.02``, it means the SoC decreased by 2%
140
- between from the start of the trip to ``timestamps[i]``. This list should typically
141
- be a monotonic decreasing sequence.
124
+ Generate consumption information for the scenario.
125
+
126
+ This function retrieves the consumption LUT and vehicle classes from the database and returns a dictionary
127
+ containing the consumption information for each vehicle type in the scenario.
128
+
129
+ :param scenario: A :class:`eflips.model.Scenario` object containing the input data for the simulation.
130
+
131
+ :return: A dictionary containing the consumption information for each vehicle type in the scenario.
142
132
  """
143
133
 
144
- delta_soc_total: float
145
- timestamps: List[datetime] | None
146
- delta_soc: List[float] | None
134
+ with create_session(scenario) as (session, scenario):
135
+ trips = session.query(Trip).filter(Trip.scenario_id == scenario.id).all()
136
+ consumption_results = {}
137
+ for trip in trips:
138
+ consumption_info = extract_trip_information(
139
+ trip.id,
140
+ scenario,
141
+ )
142
+ battery_capacity_current_vt = trip.rotation.vehicle_type.battery_capacity
143
+ consumption_result = consumption_info.generate_consumption_result(
144
+ battery_capacity_current_vt
145
+ )
146
+ consumption_results[trip.id] = consumption_result
147
+
148
+ return consumption_results
147
149
 
148
150
 
149
151
  def simple_consumption_simulation(
@@ -492,19 +494,14 @@ def generate_depot_layout(
492
494
 
493
495
  # Create one direct slot for each rotation (it's way too much, but should work)
494
496
  vt_capacity_dict: Dict[VehicleType, Dict[AreaType, None | int]] = {}
497
+ rotation_count_depot = 0
495
498
  for vehicle_type, rotations in vehicle_type_dict.items():
496
- rotation_count = len(rotations)
497
499
  vt_capacity_dict[vehicle_type] = {
498
500
  AreaType.LINE: None,
499
- AreaType.DIRECT_ONESIDE: rotation_count,
501
+ AreaType.DIRECT_ONESIDE: len(rotations),
500
502
  AreaType.DIRECT_TWOSIDE: None,
501
503
  }
502
-
503
- total_rotation_count = (
504
- session.query(Rotation)
505
- .filter(Rotation.scenario_id == scenario.id)
506
- .count()
507
- )
504
+ rotation_count_depot += len(rotations)
508
505
 
509
506
  generate_depot(
510
507
  vt_capacity_dict,
@@ -512,8 +509,8 @@ def generate_depot_layout(
512
509
  scenario,
513
510
  session,
514
511
  charging_power=charging_power,
515
- num_shunting_slots=max(total_rotation_count // 10, 1),
516
- num_cleaning_slots=max(total_rotation_count // 10, 1),
512
+ num_shunting_slots=max(rotation_count_depot // 10, 1),
513
+ num_cleaning_slots=max(rotation_count_depot // 10, 1),
517
514
  )
518
515
 
519
516
 
@@ -1097,3 +1094,138 @@ def add_evaluation_to_database(
1097
1094
  # Postprocessing of events
1098
1095
  update_vehicle_in_rotation(session, scenario, list_of_assigned_schedules)
1099
1096
  update_waiting_events(session, scenario, waiting_area_id)
1097
+
1098
+
1099
+ def generate_depot_optimal_size(
1100
+ scenario: Scenario,
1101
+ standard_block_length: int = 6,
1102
+ charging_power: float = 90,
1103
+ database_url: Optional[str] = None,
1104
+ delete_existing_depot: bool = False,
1105
+ consumption_results: Optional[Dict[int, ConsumptionResult]] = None,
1106
+ ) -> None:
1107
+ """
1108
+ Generates an optimal depot layout with the smallest possible size for each depot in the scenario. Line charging areas
1109
+ with given block length area preferred. The existing depot will be deleted if `delete_existing_depot` is set to True.
1110
+
1111
+ :param scenario: A :class:`eflips.model.Scenario` object containing the input data for the simulation.
1112
+ :param standard_block_length: The standard block length for the depot layout in meters. Default is 6.
1113
+ :param charging_power: The charging power of the charging area in kW. Default is 90.
1114
+ :param database_url: An optional database URL. Used if no database url is given by the environment variable.
1115
+ :param delete_existing_depot: If there is already a depot existing in this scenario, set True to delete this
1116
+ existing depot. Set to False and a ValueError will be raised if there is a depot in this scenario.
1117
+ :param consumption_results: A dictionary of consumption results for each vehicle type. It is used to simulate the consumption with
1118
+ given look-up tables. If not given, the constant consumption will be used in the simulation.
1119
+
1120
+ :return: None. The depot layout will be added to the database.
1121
+
1122
+ """
1123
+
1124
+ logger = logging.getLogger(__name__)
1125
+
1126
+ with create_session(scenario, database_url) as (session, scenario):
1127
+ # Delete all vehicles and events, also disconnect the vehicles from the rotations
1128
+ rotation_q = session.query(Rotation).filter(Rotation.scenario_id == scenario.id)
1129
+ rotation_q.update({"vehicle_id": None})
1130
+ session.query(Event).filter(Event.scenario_id == scenario.id).delete()
1131
+ session.query(Vehicle).filter(Vehicle.scenario_id == scenario.id).delete()
1132
+
1133
+ # Handles existing depot
1134
+ if session.query(Depot).filter(Depot.scenario_id == scenario.id).count() != 0:
1135
+ if delete_existing_depot is False:
1136
+ raise ValueError("Depot already exists.")
1137
+
1138
+ delete_depots(scenario, session)
1139
+
1140
+ ##### Step 0: Consumption Simulation #####
1141
+ # Run the consumption simulation for all depots
1142
+ simple_consumption_simulation(
1143
+ scenario, initialize_vehicles=True, consumption_result=consumption_results
1144
+ )
1145
+
1146
+ ##### Step 1: Find all potential depots #####
1147
+ # These are all the spots where a rotation starts and end
1148
+ warnings.simplefilter("ignore", category=ConsistencyWarning)
1149
+ warnings.simplefilter("ignore", category=UserWarning)
1150
+
1151
+ depot_capacities_for_scenario: Dict[
1152
+ Station, Dict[VehicleType, Dict[AreaType, int]]
1153
+ ] = {}
1154
+
1155
+ num_rotations_for_scenario: Dict[Station, int] = {}
1156
+
1157
+ for (
1158
+ first_last_stop_tup,
1159
+ vehicle_type_dict,
1160
+ ) in group_rotations_by_start_end_stop(scenario.id, session).items():
1161
+ first_stop, last_stop = first_last_stop_tup
1162
+ if first_stop != last_stop:
1163
+ raise ValueError("First and last stop of a rotation are not the same.")
1164
+
1165
+ station = first_stop
1166
+ rotation_count_depot = sum(
1167
+ len(rotations) for vehicle_type, rotations in vehicle_type_dict.items()
1168
+ )
1169
+
1170
+ savepoint = session.begin_nested()
1171
+ try:
1172
+ # (Temporarily) Delete all rotations not starting or ending at the station
1173
+ logger.debug(
1174
+ f"Deleting all rotations not starting or ending at {station.name}"
1175
+ )
1176
+ all_rot_for_scenario = (
1177
+ session.query(Rotation)
1178
+ .filter(Rotation.scenario_id == scenario.id)
1179
+ .all()
1180
+ )
1181
+ to_delete = []
1182
+ for rot in all_rot_for_scenario:
1183
+ first_stop = rot.trips[0].route.departure_station
1184
+ if first_stop != station:
1185
+ for trip in rot.trips:
1186
+ for stop_time in trip.stop_times:
1187
+ to_delete.append(stop_time)
1188
+ for event in trip.events:
1189
+ to_delete.append(event)
1190
+ to_delete.append(trip)
1191
+ to_delete.append(rot)
1192
+ for obj in to_delete:
1193
+ session.flush()
1194
+ session.delete(obj)
1195
+ session.flush()
1196
+
1197
+ logger.info(f"Generating depot layout for station {station.name}")
1198
+ vt_capacities_for_station = depot_smallest_possible_size(
1199
+ station,
1200
+ scenario,
1201
+ session,
1202
+ standard_block_length,
1203
+ charging_power,
1204
+ )
1205
+
1206
+ depot_capacities_for_scenario[station] = vt_capacities_for_station
1207
+ num_rotations_for_scenario[station] = rotation_count_depot
1208
+ finally:
1209
+ savepoint.rollback()
1210
+
1211
+ # create depot with the calculated area sizes
1212
+
1213
+ with create_session(scenario, database_url) as (session, scenario):
1214
+ for depot_station, capacities in depot_capacities_for_scenario.items():
1215
+ generate_depot(
1216
+ capacities,
1217
+ depot_station,
1218
+ scenario,
1219
+ session,
1220
+ standard_block_length=standard_block_length,
1221
+ charging_power=charging_power,
1222
+ num_shunting_slots=num_rotations_for_scenario[depot_station] // 10,
1223
+ num_cleaning_slots=num_rotations_for_scenario[depot_station] // 10,
1224
+ )
1225
+
1226
+ # Delete all vehicles and events again. Only depot layout is kept
1227
+
1228
+ rotation_q = session.query(Rotation).filter(Rotation.scenario_id == scenario.id)
1229
+ rotation_q.update({"vehicle_id": None})
1230
+ session.query(Event).filter(Event.scenario_id == scenario.id).delete()
1231
+ session.query(Vehicle).filter(Vehicle.scenario_id == scenario.id).delete()
@@ -1,9 +1,11 @@
1
1
  import logging
2
2
  import warnings
3
+ from dataclasses import dataclass
3
4
  from datetime import timedelta, datetime
4
5
  from math import ceil
5
- from typing import Tuple
6
+ from typing import Tuple, List
6
7
  from zoneinfo import ZoneInfo
8
+ import scipy
7
9
 
8
10
  import numpy as np
9
11
  import sqlalchemy.orm
@@ -12,11 +14,281 @@ from eflips.model import (
12
14
  EventType,
13
15
  Rotation,
14
16
  Vehicle,
17
+ VehicleType,
18
+ VehicleClass,
15
19
  Trip,
16
20
  Station,
17
21
  ChargeType,
18
22
  ConsistencyWarning,
23
+ ConsumptionLut,
24
+ Scenario,
19
25
  )
26
+ from sqlalchemy.orm import joinedload
27
+
28
+ from eflips.depot.api.private.util import temperature_for_trip, create_session
29
+
30
+
31
+ @dataclass
32
+ class ConsumptionResult:
33
+ """
34
+ A dataclass that stores the results of a charging simulation for a single trip.
35
+
36
+ This class holds both the total change in battery State of Charge (SoC) over the trip
37
+ as well as an optional timeseries of timestamps and incremental SoC changes. When
38
+ an entry exists for a given trip in ``consumption_result``, the simulation will use
39
+ these precomputed values instead of recalculating the SoC changes from the vehicle
40
+ distance and consumption.
41
+
42
+ :param delta_soc_total:
43
+ The total change in the vehicle's State of Charge over the trip, typically
44
+ negative if the vehicle is consuming energy (e.g., -0.15 means the SoC
45
+ dropped by 15%).
46
+
47
+ :param timestamps:
48
+ A list of timestamps (e.g., arrival times at stops) that mark the times
49
+ associated with the SoC changes. The number of timestamps must match the
50
+ number of entries in ``delta_soc``.
51
+
52
+ :param delta_soc:
53
+ A list of cumulative SoC changes corresponding to the ``timestamps``.
54
+ For example, if ``delta_soc[i] = -0.02``, it means the SoC decreased by 2%
55
+ between from the start of the trip to ``timestamps[i]``. This list should typically
56
+ be a monotonic decreasing sequence.
57
+ """
58
+
59
+ delta_soc_total: float
60
+ timestamps: List[datetime] | None
61
+ delta_soc: List[float] | None
62
+
63
+
64
+ @dataclass
65
+ class ConsumptionInformation:
66
+ """
67
+ A dataclass to hold the information needed for the consumption simulation.
68
+
69
+ :param trip_id:
70
+ The ID of the trip for which the consumption is calculated.
71
+ :param consumption_lut:
72
+ The ConsumptionLut object for the vehicle class. This is used to calculate the
73
+ consumption based on the trip parameters.
74
+ :param average_speed:
75
+ The average speed of the trip in km/h. This is used to calculate the consumption.
76
+ :param distance:
77
+ The distance of the trip in km. This is used to calculate the total consumption.
78
+ :param temperature:
79
+ The ambient temperature in °C. This is used to calculate the consumption.
80
+ :param level_of_loading:
81
+ The level of loading of the vehicle as a fraction of its maximum payload.
82
+ :param incline:
83
+ The incline of the trip as a fraction (0.0-1.0). This is used to calculate the consumption.
84
+ :param consumption:
85
+ The total consumption of the trip in kWh. This is calculated based on the LUT and trip parameters.
86
+ :param consumption_per_km:
87
+ The consumption per km in kWh. This is calculated based on the LUT and trip parameters.
88
+ """
89
+
90
+ trip_id: int
91
+ consumption_lut: ConsumptionLut | None # the LUT for the vehicle class
92
+ average_speed: float # the average speed of the trip in km/h
93
+ distance: float # the distance of the trip in km
94
+ temperature: float # The ambient temperature in °C
95
+ level_of_loading: float
96
+ incline: float = 0.0 # The incline of the trip in 0.0-1.0
97
+ consumption: float = None # The consumption of the trip in kWh
98
+ consumption_per_km: float = None # The consumption per km in kWh
99
+
100
+ def calculate(self):
101
+ """
102
+ Calculates the consumption for the trip. Returns a float in kWh.
103
+
104
+ :return: The energy consumption in kWh. This is already the consumption for the whole trip.
105
+ """
106
+
107
+ # Make sure the consumption lut has 4 dimensions and the columns are in the correct order
108
+ if self.consumption_lut.columns != [
109
+ "incline",
110
+ "t_amb",
111
+ "level_of_loading",
112
+ "mean_speed_kmh",
113
+ ]:
114
+ raise ValueError(
115
+ "The consumption LUT must have the columns 'incline', 't_amb', 'level_of_loading', 'mean_speed_kmh'"
116
+ )
117
+
118
+ # Recover the scales along each of the four axes from the datapoints
119
+ incline_scale = sorted(set([x[0] for x in self.consumption_lut.data_points]))
120
+ temperature_scale = sorted(
121
+ set([x[1] for x in self.consumption_lut.data_points])
122
+ )
123
+ level_of_loading_scale = sorted(
124
+ set([x[2] for x in self.consumption_lut.data_points])
125
+ )
126
+ speed_scale = sorted(set([x[3] for x in self.consumption_lut.data_points]))
127
+
128
+ # Create the 4d array
129
+ consumption_lut = np.zeros(
130
+ (
131
+ len(incline_scale),
132
+ len(temperature_scale),
133
+ len(level_of_loading_scale),
134
+ len(speed_scale),
135
+ )
136
+ )
137
+
138
+ # Fill it with NaNs
139
+ consumption_lut.fill(np.nan)
140
+
141
+ for i, (incline, temperature, level_of_loading, speed) in enumerate(
142
+ self.consumption_lut.data_points
143
+ ):
144
+ consumption_lut[
145
+ incline_scale.index(incline),
146
+ temperature_scale.index(temperature),
147
+ level_of_loading_scale.index(level_of_loading),
148
+ speed_scale.index(speed),
149
+ ] = self.consumption_lut.values[i]
150
+
151
+ # Interpolate the consumption
152
+ interpolator = scipy.interpolate.RegularGridInterpolator(
153
+ (incline_scale, temperature_scale, level_of_loading_scale, speed_scale),
154
+ consumption_lut,
155
+ bounds_error=False,
156
+ fill_value=None,
157
+ method="linear",
158
+ )
159
+ consumption_per_km = interpolator(
160
+ [self.incline, self.temperature, self.level_of_loading, self.average_speed]
161
+ )[0]
162
+
163
+ # This is a temporary workaround to handle cases where the LUT does not contain
164
+ if consumption_per_km is None or np.isnan(consumption_per_km):
165
+ # Add a warning if we had to use nearest neighbor interpolation
166
+ warnings.warn(
167
+ f"Consumption LUT for trip {self.trip_id} with parameters: "
168
+ f"incline={self.incline}, temperature={self.temperature}, "
169
+ f"level_of_loading={self.level_of_loading}, average_speed={self.average_speed} "
170
+ f"returned NaN. Using nearest neighbor interpolation instead. The result may be less accurate.",
171
+ ConsistencyWarning,
172
+ )
173
+
174
+ interpolator_nn = scipy.interpolate.RegularGridInterpolator(
175
+ (incline_scale, temperature_scale, level_of_loading_scale, speed_scale),
176
+ consumption_lut,
177
+ bounds_error=False,
178
+ fill_value=None, # Fill NaN with 0.0
179
+ method="nearest",
180
+ )
181
+ consumption_per_km = interpolator_nn(
182
+ [
183
+ self.incline,
184
+ self.temperature,
185
+ self.level_of_loading,
186
+ self.average_speed,
187
+ ]
188
+ )[0]
189
+
190
+ # Add a warning if we had to use nearest neighbor interpolation
191
+
192
+ if consumption_per_km is None or np.isnan(consumption_per_km):
193
+ raise ValueError(
194
+ f"Could not calculate consumption for trip {self.trip_id} with parameters: "
195
+ f"incline={self.incline}, temperature={self.temperature}, "
196
+ f"level_of_loading={self.level_of_loading}, average_speed={self.average_speed}. "
197
+ f"Possible reason: data points missing in the LUT."
198
+ )
199
+
200
+ self.consumption = consumption_per_km * self.distance
201
+ self.consumption_per_km = consumption_per_km
202
+ self.consumption_lut = None # To save memory
203
+
204
+ def generate_consumption_result(self, battery_capacity) -> ConsumptionResult:
205
+ """
206
+ Generates a ConsumptionResult object from the current instance.
207
+
208
+ :param battery_capacity: The battery capacity in kWh.
209
+ :return: A ConsumptionResult object containing the total change in SoC and optional timeseries.
210
+ """
211
+ if self.consumption is None:
212
+ raise ValueError(
213
+ "Consumption must be calculated before generating a result."
214
+ )
215
+
216
+ # TODO implement a timeseries of timestamps and delta_soc
217
+ consumption_result = ConsumptionResult(
218
+ delta_soc_total=-float(self.consumption) / battery_capacity,
219
+ timestamps=None,
220
+ delta_soc=None,
221
+ )
222
+ return consumption_result
223
+
224
+
225
+ def extract_trip_information(
226
+ trip_id: int,
227
+ scenario: Scenario,
228
+ passenger_mass=68,
229
+ passenger_count=17.6,
230
+ ) -> ConsumptionInformation:
231
+ """
232
+ Extracts the information needed for the consumption simulation from a trip.
233
+ """
234
+
235
+ with create_session(scenario) as (session, scenario):
236
+ # Load the trip with its route and rotation, including vehicle type and consumption LUT
237
+ # We use joinedload to avoid N+1 queries
238
+
239
+ trip = (
240
+ session.query(Trip)
241
+ .filter(Trip.id == trip_id)
242
+ .options(joinedload(Trip.route))
243
+ .options(
244
+ joinedload(Trip.rotation)
245
+ .joinedload(Rotation.vehicle_type)
246
+ .joinedload(VehicleType.vehicle_classes)
247
+ .joinedload(VehicleClass.consumption_lut)
248
+ )
249
+ .one()
250
+ )
251
+ # Check exactly one of the vehicle classes has a consumption LUT
252
+ all_consumption_luts = [
253
+ vehicle_class.consumption_lut
254
+ for vehicle_class in trip.rotation.vehicle_type.vehicle_classes
255
+ ]
256
+ all_consumption_luts = [x for x in all_consumption_luts if x is not None]
257
+ if len(all_consumption_luts) != 1:
258
+ raise ValueError(
259
+ f"Expected exactly one consumption LUT, got {len(all_consumption_luts)}"
260
+ )
261
+ consumption_lut = all_consumption_luts[0]
262
+ # Disconnect the consumption LUT from the session to avoid loading the whole table
263
+
264
+ del all_consumption_luts
265
+
266
+ total_distance = trip.route.distance / 1000 # km
267
+ total_duration = (
268
+ trip.arrival_time - trip.departure_time
269
+ ).total_seconds() / 3600
270
+ average_speed = total_distance / total_duration # km/h
271
+
272
+ temperature = temperature_for_trip(trip_id, session)
273
+
274
+ payload_mass = passenger_mass * passenger_count
275
+ full_payload = (
276
+ trip.rotation.vehicle_type.allowed_mass
277
+ - trip.rotation.vehicle_type.empty_mass
278
+ )
279
+ level_of_loading = payload_mass / full_payload
280
+
281
+ info = ConsumptionInformation(
282
+ trip_id=trip.id,
283
+ consumption_lut=consumption_lut,
284
+ average_speed=average_speed,
285
+ distance=total_distance,
286
+ temperature=temperature,
287
+ level_of_loading=level_of_loading,
288
+ )
289
+
290
+ info.calculate()
291
+ return info
20
292
 
21
293
 
22
294
  def initialize_vehicle(rotation: Rotation, session: sqlalchemy.orm.session.Session):
@@ -1070,7 +1070,8 @@ def depot_smallest_possible_size(
1070
1070
  logger.debug(
1071
1071
  f"Vehicle count for {vt.name} in {amount_of_line_areas} line areas configuration: {vehicle_count_q}. This is > than the all-direct configuration ({vehicle_counts_all_direct[vt]})."
1072
1072
  )
1073
- except ValueError as e:
1073
+ except Exception as e:
1074
+ # This change is made after Unstable exception and delay exceptions are introduced
1074
1075
  if (
1075
1076
  "which suggests the fleet or the infrastructure might not be enough for the full electrification. Please add charging interfaces or increase charging power ."
1076
1077
  in repr(e)
@@ -67,7 +67,7 @@ def get_finished_schedules_per_vehicle(
67
67
  }
68
68
  if i == 0:
69
69
  raise UnstableSimulationException(
70
- f"New Vehicle required for the trip {current_trip.ID}, which suggests the fleet or the "
70
+ f"New Vehicle required for the rotation/block {current_trip.ID}, which suggests the fleet or the "
71
71
  f"infrastructure might not be enough for the full electrification. Please add charging "
72
72
  f"interfaces or increase charging power ."
73
73
  )
@@ -7,6 +7,7 @@ from datetime import timedelta, datetime
7
7
  from typing import Union, Any, Optional, Tuple, Dict, List
8
8
 
9
9
  import simpy
10
+ import numpy as np
10
11
  from eflips.model import (
11
12
  Scenario,
12
13
  VehicleType,
@@ -15,6 +16,7 @@ from eflips.model import (
15
16
  EventType,
16
17
  Trip,
17
18
  Depot,
19
+ Temperatures,
18
20
  )
19
21
  from sqlalchemy import inspect, create_engine
20
22
  from sqlalchemy.orm import Session
@@ -199,6 +201,38 @@ def check_depot_validity(depot: Depot) -> None:
199
201
  ), "All processes except the last one must have electric power."
200
202
 
201
203
 
204
+ def temperature_for_trip(trip_id: int, session: Session) -> float:
205
+ """
206
+ Returns the temperature for a trip. Finds the temperature for the mid-point of the trip.
207
+
208
+ :param trip_id: The ID of the trip
209
+ :param session: The SQLAlchemy session
210
+ :return: A temperature in °C
211
+ """
212
+
213
+ trip = session.query(Trip).filter(Trip.id == trip_id).one()
214
+ temperatures = (
215
+ session.query(Temperatures)
216
+ .filter(Temperatures.scenario_id == trip.scenario_id)
217
+ .one()
218
+ )
219
+
220
+ # Find the mid-point of the trip
221
+ mid_time = trip.departure_time + (trip.arrival_time - trip.departure_time) / 2
222
+
223
+ if temperatures.use_only_time:
224
+ # The temperatures are only given by time. We change our mid-time to be the date of the temperatures
225
+ mid_time = datetime.combine(temperatures.datetimes[0].date(), mid_time.time())
226
+
227
+ mid_time = mid_time.timestamp()
228
+
229
+ datetimes = [dt.timestamp() for dt in temperatures.datetimes]
230
+ temperatures = temperatures.data
231
+
232
+ temperature = np.interp(mid_time, datetimes, temperatures)
233
+ return float(temperature)
234
+
235
+
202
236
  @dataclass
203
237
  class VehicleSchedule:
204
238
  """
@@ -1514,6 +1514,10 @@ class DepotControl:
1514
1514
 
1515
1515
  def schedule_for_matching(self, trip):
1516
1516
  """Wait *delay* and then schedule *trip* for matching with a vehicle."""
1517
+
1518
+ # Shuyao: This delay does not mean the trip is delayed. It means the trip must be scheduled one hour before departure
1519
+ # so the time of the environment is "delayed" until 1 hour before departure.
1520
+
1517
1521
  delay = self.dispatch_strategy.scheduling_delay(self.env, trip)
1518
1522
  yield self.env.timeout(delay)
1519
1523
  self.depot.unassigned_trips.append(trip)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "eflips-depot"
3
- version = "4.9.1"
3
+ version = "4.11.0"
4
4
  description = "Depot Simulation for eFLIPS"
5
5
  authors = ["Enrico Lauth <enrico.lauth@tu-berlin.de>",
6
6
  "Ludger Heide <ludger.heide@tu-berlin.de",
File without changes
File without changes