eflips-depot 4.4.3__tar.gz → 4.6.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 (43) hide show
  1. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/PKG-INFO +3 -3
  2. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/__init__.py +4 -0
  3. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/api/__init__.py +249 -198
  4. eflips_depot-4.6.0/eflips/depot/api/private/consumption.py +389 -0
  5. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/pyproject.toml +1 -1
  6. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/LICENSE.md +0 -0
  7. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/README.md +0 -0
  8. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/api/defaults/default_settings.json +0 -0
  9. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/api/private/__init__.py +0 -0
  10. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/api/private/depot.py +0 -0
  11. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/api/private/results_to_database.py +0 -0
  12. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/api/private/smart_charging.py +0 -0
  13. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/api/private/util.py +0 -0
  14. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/configuration.py +0 -0
  15. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/depot.py +0 -0
  16. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/evaluation.py +0 -0
  17. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/filters.py +0 -0
  18. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/input_epex_power_price.py +0 -0
  19. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/__init__.py +0 -0
  20. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/doc/__init__.py +0 -0
  21. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/doc/direct_details.pdf +0 -0
  22. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/evaluation.py +0 -0
  23. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/opt_tools/__init__.py +0 -0
  24. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/opt_tools/crossover.py +0 -0
  25. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/opt_tools/fitness_c_urfd.py +0 -0
  26. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/opt_tools/fitness_util.py +0 -0
  27. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/opt_tools/init.py +0 -0
  28. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/opt_tools/mutation.py +0 -0
  29. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/optimize_c_urfd.py +0 -0
  30. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/packing.py +0 -0
  31. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/settings.py +0 -0
  32. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/template_creation.py +0 -0
  33. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/layout_opt/util.py +0 -0
  34. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/plots.py +0 -0
  35. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/processes.py +0 -0
  36. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/rating.py +0 -0
  37. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/resources.py +0 -0
  38. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/settings_config.py +0 -0
  39. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/simple_vehicle.py +0 -0
  40. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/simulation.py +0 -0
  41. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/smart_charging.py +0 -0
  42. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/standalone.py +0 -0
  43. {eflips_depot-4.4.3 → eflips_depot-4.6.0}/eflips/depot/validation.py +0 -0
@@ -1,8 +1,7 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: eflips-depot
3
- Version: 4.4.3
3
+ Version: 4.6.0
4
4
  Summary: Depot Simulation for eFLIPS
5
- Home-page: https://github.com/mpm-tu-berlin/eflips-depot
6
5
  License: AGPL-3.0-or-later
7
6
  Author: Enrico Lauth
8
7
  Author-email: enrico.lauth@tu-berlin.de
@@ -21,6 +20,7 @@ Requires-Dist: simpy (>=4.0.1,<5.0.0)
21
20
  Requires-Dist: tqdm (>=4.67.0,<5.0.0)
22
21
  Requires-Dist: xlrd (<=1.2.0)
23
22
  Requires-Dist: xlsxwriter (>=3.1.9,<4.0.0)
23
+ Project-URL: Homepage, https://github.com/mpm-tu-berlin/eflips-depot
24
24
  Project-URL: Repository, https://github.com/mpm-tu-berlin/eflips-depot
25
25
  Description-Content-Type: text/markdown
26
26
 
@@ -53,3 +53,7 @@ from eflips.depot.simulation import (
53
53
  from eflips.depot.smart_charging import SmartCharging, ControlSmartCharging
54
54
  from eflips.depot.standalone import VehicleGenerator, SimpleTrip, Timetable
55
55
  from eflips.depot.validation import Validator
56
+
57
+
58
+ class UnstableSimulationException(Exception):
59
+ pass
@@ -25,15 +25,15 @@ The following steps are recommended for using the API:
25
25
  b. Run the :func:`simple_consumption_simulation` function again, this time with ``initialize_vehicles=False``.
26
26
  """
27
27
  import copy
28
- import datetime
29
28
  import logging
30
29
  import os
31
30
  import warnings
32
31
  from collections import OrderedDict
33
- from datetime import timedelta
32
+ from dataclasses import dataclass
33
+ from datetime import timedelta, datetime
34
34
  from enum import Enum
35
35
  from math import ceil
36
- from typing import Any, Dict, Optional, Union
36
+ from typing import Any, Dict, Optional, Union, List
37
37
 
38
38
  import sqlalchemy.orm
39
39
  from eflips.model import (
@@ -47,6 +47,7 @@ from eflips.model import (
47
47
  Vehicle,
48
48
  VehicleType,
49
49
  AreaType,
50
+ ChargeType,
50
51
  )
51
52
  from sqlalchemy.orm import Session
52
53
 
@@ -55,6 +56,11 @@ from eflips.depot import (
55
56
  DepotEvaluation,
56
57
  SimulationHost,
57
58
  )
59
+ from eflips.depot.api.private.consumption import (
60
+ initialize_vehicle,
61
+ add_initial_standby_event,
62
+ attempt_opportunity_charging_event,
63
+ )
58
64
  from eflips.depot.api.private.depot import (
59
65
  delete_depots,
60
66
  depot_to_template,
@@ -107,47 +113,131 @@ class SmartChargingStrategy(Enum):
107
113
  """
108
114
 
109
115
 
116
+ @dataclass
117
+ class ConsumptionResult:
118
+ """
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.
142
+ """
143
+
144
+ delta_soc_total: float
145
+ timestamps: List[datetime] | None
146
+ delta_soc: List[float] | None
147
+
148
+
110
149
  def simple_consumption_simulation(
111
150
  scenario: Union[Scenario, int, Any],
112
151
  initialize_vehicles: bool,
113
152
  database_url: Optional[str] = None,
114
153
  calculate_timeseries: bool = False,
115
154
  terminus_deadtime: timedelta = timedelta(minutes=1),
155
+ consumption_result: Dict[int, ConsumptionResult] | None = None,
116
156
  ) -> None:
117
157
  """
118
- A simple consumption simulation and vehicle initialization.
119
-
120
- Energy consumotion is calculated by multiplying the vehicle's total distance by a constant
121
- ``VehicleType.consumption``.
122
-
123
- If run with ``initialize_vehicles=True``, the method will also initialize the vehicles in the database with the
124
- correct vehicle type and assign them to rotations. If this is false, it will assume that there are already vehicle
125
- entries and ``Rotation.vehicle_id`` is already set.
126
-
127
- :param scenario: Either a :class:`eflips.model.Scenario` object containing the input data for the simulation. Or
128
- an integer specifying the ID of a scenario in the database. Or any other object that has an attribute
129
- ``id`` that is an integer. If no :class:`eflips.model.Scenario` object is passed, the ``database_url``
130
- parameter must be set to a valid database URL ot the environment variable ``DATABASE_URL`` must be set to a
131
- valid database URL.
132
-
133
- :param initialize_vehicles: A boolean flag indicating whether the vehicles should be initialized in the database.
134
- When running this function for the first time, this should be set to True. When running this function again
135
- after the vehicles have been initialized, this should be set to False.
136
-
137
- :param database_url: An optional database URL. If no database URL is passed and the `scenario` parameter is not a
138
- :class:`eflips.model.Scenario` object, the environment variable `DATABASE_URL` must be set to a
139
- valid database URL.
140
-
141
- :param calculate_timeseries: A boolean flag indicating whether the timeseries should be calculated. If this is set
142
- to True, the SoC at each stop is calculated and added to the "timeseries" column of the Event table. If this
143
- is set to False, the "timeseries" column of the Event table will be set to ``None``. Setting this to false
144
- may significantly speed up the simulation.
145
-
146
- :param terminus_deadtime: The total deadtime taken to both attach and detach the charging cable at the terminus.
147
- If the total deadtime is greater than the time between the arrival and departure of the
148
- vehicle at the terminus, the vehicle will not be able to charge at the terminus.
149
-
150
- :return: Nothing. The results are added to the database.
158
+ Run a simple consumption simulation and optionally initialize vehicles in the database.
159
+
160
+ This function calculates energy consumption by multiplying each vehicle's total traveled
161
+ distance by a constant ``VehicleType.consumption`` (kWh per km), then updates the database
162
+ with the resulting SoC (State of Charge) data. The function can also use precomputed results
163
+ for specific trips via the ``consumption_result`` parameter.
164
+
165
+ If ``initialize_vehicles`` is True, vehicles and an initial STANDBY event (with 100% SoC)
166
+ are created for each rotation that does not already have a vehicle. If it is False, existing
167
+ vehicles in the database are assumed, and a check is performed to ensure each rotation has a
168
+ vehicle.
169
+
170
+ Opportunity charging can optionally be applied at the end of each trip, if the vehicle and
171
+ station both allow it, and if the rotation is flagged to allow it. This charging event is
172
+ constrained by a configurable terminus deadtime.
173
+
174
+ **SoC Constraints**
175
+
176
+ - When no precomputed results are provided, SoC is computed by subtracting energy used
177
+ (`consumption * distance / battery_capacity`) from the previous event’s SoC.
178
+ - When precomputed ``ConsumptionResult`` objects are provided in ``consumption_result``,
179
+ they must have a non-positive total change in SoC (``delta_soc_total <= 0``).
180
+ If the function detects a positive ``delta_soc_total``, it raises a ``ValueError``.
181
+
182
+ **Timeseries Calculation**
183
+
184
+ - If ``calculate_timeseries`` is True, the function builds a more granular SoC timeseries
185
+ at each stop in the trip and stores it in the ``Event.timeseries`` column.
186
+ - If False, the event’s ``timeseries`` is set to ``None``, which may speed up the simulation
187
+ if you do not need intermediate SoC data.
188
+
189
+ :param scenario:
190
+ One of:
191
+ - A :class:`eflips.model.Scenario` instance containing the input data for the simulation.
192
+ - An integer specifying the ID of a scenario in the database.
193
+ - Any other object with an integer ``id`` attribute.
194
+
195
+ If not passing a :class:`eflips.model.Scenario` directly, the `database_url` parameter
196
+ or the environment variable ``DATABASE_URL`` must point to a valid database.
197
+
198
+ :param initialize_vehicles:
199
+ A boolean flag indicating whether new vehicles should be created and assigned
200
+ to rotations in the database. Set this to True the first time you run the simulation
201
+ so that vehicles are initialized. In subsequent runs, set to False if vehicles
202
+ are already present.
203
+
204
+ :param database_url:
205
+ A database connection string (e.g., ``postgresql://user:pass@host/db``).
206
+ If you do not provide this and ``scenario`` is not a
207
+ :class:`eflips.model.Scenario` instance, the environment variable
208
+ ``DATABASE_URL`` must be set.
209
+
210
+ :param calculate_timeseries:
211
+ If True, each trip’s detailed SoC timeseries is computed and stored in the
212
+ ``timeseries`` column of the corresponding driving and charging events.
213
+ If False, only the start/end SoC is recorded, and ``timeseries`` is set to None.
214
+
215
+ :param terminus_deadtime:
216
+ The total time overhead (attach + detach) for charging at the terminus.
217
+ If this deadtime exceeds the available layover time, no charging is performed.
218
+
219
+ :param consumption_result:
220
+ A dictionary mapping trip IDs to :class:`ConsumptionResult` instances for
221
+ precomputed SoC changes. If an entry exists for a trip, this function uses
222
+ those precomputed SoC changes instead of calculating them from distance
223
+ and consumption. Each ``ConsumptionResult`` must have:
224
+
225
+ - A non-positive ``delta_soc_total`` (<= 0).
226
+ - Optionally, matching lists of timestamps and delta SoC values that are
227
+ decreasing (i.e., the vehicle only loses or maintains SoC).
228
+
229
+ :returns:
230
+ ``None``. All simulation results are written directly to the database as
231
+ :class:`eflips.model.Event` entries.
232
+
233
+ :raises ValueError:
234
+ - If a rotation in the scenario does not have a vehicle when
235
+ ``initialize_vehicles=False``.
236
+ - If the vehicle type has no ``consumption`` value.
237
+ - If a provided ``ConsumptionResult`` has inconsistent list lengths,
238
+ or if its ``delta_soc_total`` is positive.
239
+ - If SoC timeseries are not decreasing when provided
240
+ via ``consumption_result``.
151
241
  """
152
242
  logger = logging.getLogger(__name__)
153
243
 
@@ -162,76 +252,18 @@ def simple_consumption_simulation(
162
252
  )
163
253
  if initialize_vehicles:
164
254
  for rotation in rotations:
165
- vehicle = Vehicle(
166
- vehicle_type_id=rotation.vehicle_type_id,
167
- scenario_id=scenario.id,
168
- name=f"Vehicle for rotation {rotation.id}",
169
- )
170
- session.add(vehicle)
171
- rotation.vehicle = vehicle
255
+ initialize_vehicle(rotation, session)
172
256
 
173
- # Additionally, add a short STANDBY event with 100% SoC immediately before the first trip
174
- first_trip_start = rotation.trips[0].departure_time
175
- standby_start = first_trip_start - timedelta(seconds=1)
176
- standby_event = Event(
177
- scenario_id=scenario.id,
178
- vehicle_type_id=rotation.vehicle_type_id,
179
- vehicle=vehicle,
180
- station_id=rotation.trips[0].route.departure_station_id,
181
- subloc_no=0,
182
- time_start=standby_start,
183
- time_end=first_trip_start,
184
- soc_start=1,
185
- soc_end=1,
186
- event_type=EventType.CHARGING_OPPORTUNITY,
187
- description=f"DUMMY Initial standby event for rotation {rotation.id}.",
188
- timeseries=None,
189
- )
190
- session.add(standby_event)
191
- else:
192
- for rotation in rotations:
193
- if rotation.vehicle is None:
194
- raise ValueError(
195
- "The rotation does not have a vehicle assigned to it."
196
- )
197
-
198
- vehicles = (
199
- session.query(Vehicle).filter(Vehicle.scenario_id == scenario.id).all()
200
- )
201
- for vehicle in vehicles:
202
- if (
203
- session.query(Event).filter(Event.vehicle_id == vehicle.id).count()
204
- == 0
205
- ):
206
- # Also add a dummy standby-departure event if this vehicle has no events
207
- rotation_per_vehicle = sorted(
208
- vehicle.rotations, key=lambda r: r.trips[0].departure_time
209
- )
210
- earliest_trip = rotation_per_vehicle[0].trips[0]
211
- area = (
212
- session.query(Area)
213
- .filter(Area.scenario_id == scenario.id)
214
- .filter(Area.vehicle_type_id == Vehicle.vehicle_type_id)
215
- .first()
216
- )
257
+ for rotation in rotations:
258
+ if rotation.vehicle is None:
259
+ raise ValueError("The rotation does not have a vehicle assigned to it.")
217
260
 
218
- standby_start = earliest_trip.departure_time - timedelta(seconds=1)
219
- standby_event = Event(
220
- scenario_id=scenario.id,
221
- vehicle_type_id=vehicle.vehicle_type_id,
222
- vehicle=vehicle,
223
- station_id=area.depot.station_id,
224
- area_id=area.id,
225
- subloc_no=area.capacity,
226
- time_start=standby_start,
227
- time_end=earliest_trip.departure_time,
228
- soc_start=1,
229
- soc_end=1,
230
- event_type=EventType.STANDBY_DEPARTURE,
231
- description=f"DUMMY Initial standby event for rotation {earliest_trip.rotation_id}.",
232
- timeseries=None,
233
- )
234
- session.add(standby_event)
261
+ vehicles = (
262
+ session.query(Vehicle).filter(Vehicle.scenario_id == scenario.id).all()
263
+ )
264
+ for vehicle in vehicles:
265
+ if session.query(Event).filter(Event.vehicle_id == vehicle.id).count() == 0:
266
+ add_initial_standby_event(vehicle, session)
235
267
 
236
268
  # Since we are doing no_autoflush blocks later, we need to flush the session once here so that unflushed stuff
237
269
  # From preceding functions is visible in the database
@@ -253,9 +285,15 @@ def simple_consumption_simulation(
253
285
  .one()
254
286
  )
255
287
  if vehicle_type.consumption is None:
256
- raise ValueError(
257
- "The vehicle type does not have a consumption value set."
258
- )
288
+ # If the vehicle type has no consumption value, all trips must have a precomputed consumption result
289
+ all_trip_ids = [trip.id for trip in rotation.trips]
290
+ if not (
291
+ consumption_result is not None
292
+ and all(trip_id in consumption_result for trip_id in all_trip_ids)
293
+ ):
294
+ raise ValueError(
295
+ "The vehicle type does not have a consumption value set and no consumption results are provided."
296
+ )
259
297
  consumption = vehicle_type.consumption
260
298
 
261
299
  # The departure SoC for this rotation is the SoC of the last event preceding the first trip
@@ -270,36 +308,79 @@ def simple_consumption_simulation(
270
308
 
271
309
  for trip in rotation.trips:
272
310
  # Set up a timeseries
273
- soc_start = current_soc
274
- if calculate_timeseries and len(trip.stop_times) > 0:
275
- timeseries = {
276
- "time": [],
277
- "soc": [],
278
- "distance": [],
279
- }
280
- for i in range(len(trip.stop_times)):
281
- current_time = trip.stop_times[i].arrival_time
282
- dwell_duration = trip.stop_times[i].dwell_duration
283
- elapsed_distance = trip.route.assoc_route_stations[
284
- i
285
- ].elapsed_distance
286
- elapsed_energy = consumption * (elapsed_distance / 1000) # kWh
287
- soc = (
288
- current_soc - elapsed_energy / vehicle_type.battery_capacity
289
- )
290
- timeseries["time"].append(current_time.isoformat())
291
- timeseries["soc"].append(soc)
292
- timeseries["distance"].append(elapsed_distance)
293
- if dwell_duration > timedelta(seconds=0):
294
- timeseries["time"].append(
295
- (current_time + dwell_duration).isoformat()
311
+ if consumption_result is None or trip.id not in consumption_result:
312
+ logger.debug("Calculating consumption for trip %s", trip.id)
313
+ soc_start = current_soc
314
+ if calculate_timeseries and len(trip.stop_times) > 0:
315
+ timeseries = {
316
+ "time": [],
317
+ "soc": [],
318
+ "distance": [],
319
+ }
320
+ for i in range(len(trip.stop_times)):
321
+ current_time = trip.stop_times[i].arrival_time
322
+ dwell_duration = trip.stop_times[i].dwell_duration
323
+ elapsed_distance = trip.route.assoc_route_stations[
324
+ i
325
+ ].elapsed_distance
326
+ elapsed_energy = consumption * (
327
+ elapsed_distance / 1000
328
+ ) # kWh
329
+ soc = (
330
+ current_soc
331
+ - elapsed_energy / vehicle_type.battery_capacity
296
332
  )
333
+ timeseries["time"].append(current_time.isoformat())
297
334
  timeseries["soc"].append(soc)
298
335
  timeseries["distance"].append(elapsed_distance)
336
+ if dwell_duration > timedelta(seconds=0):
337
+ timeseries["time"].append(
338
+ (current_time + dwell_duration).isoformat()
339
+ )
340
+ timeseries["soc"].append(soc)
341
+ timeseries["distance"].append(elapsed_distance)
342
+ else:
343
+ timeseries = None
344
+ energy_used = consumption * trip.route.distance / 1000 # kWh
345
+ current_soc = (
346
+ soc_start - energy_used / vehicle_type.battery_capacity
347
+ )
299
348
  else:
300
- timeseries = None
301
- energy_used = consumption * trip.route.distance / 1000 # kWh
302
- current_soc = soc_start - energy_used / vehicle_type.battery_capacity
349
+ logger.debug(f"Using pre-calculated timeseries for trip {trip.id}")
350
+ if (
351
+ calculate_timeseries
352
+ and consumption_result[trip.id].timestamps is not None
353
+ ):
354
+ assert consumption_result[trip.id].delta_soc is not None
355
+ timestamps = consumption_result[trip.id].timestamps
356
+
357
+ # Make sure the delta_soc is a monotonic decreasing function, with the same length as timestamps
358
+ if len(consumption_result[trip.id].delta_soc) != len(
359
+ timestamps
360
+ ):
361
+ raise ValueError(
362
+ "The length of the delta_soc and timestamps lists must be the same."
363
+ )
364
+ delta_socs = consumption_result[trip.id].delta_soc
365
+ if delta_socs[-1] > 0:
366
+ raise ValueError(
367
+ "The delta_soc must be a decreasing function."
368
+ )
369
+
370
+ socs = [current_soc + d for d in delta_socs]
371
+ timeseries = {
372
+ "time": [t.isoformat() for t in timestamps],
373
+ "soc": socs,
374
+ }
375
+ else:
376
+ timeseries = None
377
+
378
+ if consumption_result[trip.id].delta_soc_total > 0:
379
+ raise ValueError(
380
+ "The current SoC must be <= 0 when using a consumption result."
381
+ )
382
+ soc_start = current_soc
383
+ current_soc += consumption_result[trip.id].delta_soc_total
303
384
 
304
385
  # Create a driving event
305
386
  current_event = Event(
@@ -328,68 +409,20 @@ def simple_consumption_simulation(
328
409
  rotation.vehicle_type.opportunity_charging_capable
329
410
  and rotation.allow_opportunity_charging
330
411
  and trip.route.arrival_station.is_electrified
412
+ and trip.route.arrival_station.charge_type == ChargeType.OPPORTUNITY
331
413
  and trip != rotation.trips[-1]
332
414
  ):
333
- logger.debug(
334
- f"Adding opportunity charging event for trip {trip.id}"
335
- )
336
- # Identify the break time between trips
337
415
  trip_index = rotation.trips.index(trip)
338
416
  next_trip = rotation.trips[trip_index + 1]
339
- break_time = next_trip.departure_time - trip.arrival_time
340
-
341
- # How much energy can be charged in this time?
342
- energy_charged = (
343
- max([v[1] for v in vehicle_type.charging_curve])
344
- * (
345
- break_time.total_seconds()
346
- - terminus_deadtime.total_seconds()
347
- )
348
- / 3600
349
- )
350
-
351
- if energy_charged > 0:
352
- # Calculate the end SoC
353
- post_charge_soc = min(
354
- current_soc
355
- + energy_charged / vehicle_type.battery_capacity,
356
- 1,
357
- )
358
-
359
- # Create a simple timeseries for the charging event
360
- timeseries = {
361
- "time": [
362
- trip.arrival_time.isoformat(),
363
- (trip.arrival_time + terminus_deadtime / 2).isoformat(),
364
- (
365
- next_trip.departure_time - terminus_deadtime / 2
366
- ).isoformat(),
367
- next_trip.departure_time.isoformat(),
368
- ],
369
- "soc": [
370
- current_soc,
371
- current_soc,
372
- post_charge_soc,
373
- post_charge_soc,
374
- ],
375
- }
376
417
 
377
- # Create the charging event
378
- current_event = Event(
379
- scenario_id=scenario.id,
380
- vehicle_type_id=rotation.vehicle_type_id,
381
- vehicle=vehicle,
382
- station_id=trip.route.arrival_station_id,
383
- time_start=trip.arrival_time,
384
- time_end=next_trip.departure_time,
385
- soc_start=current_soc,
386
- soc_end=post_charge_soc,
387
- event_type=EventType.CHARGING_OPPORTUNITY,
388
- description=f"Opportunity charging event for trip {trip.id}.",
389
- timeseries=timeseries,
390
- )
391
- current_soc = post_charge_soc
392
- session.add(current_event)
418
+ current_soc = attempt_opportunity_charging_event(
419
+ previous_trip=trip,
420
+ next_trip=next_trip,
421
+ vehicle=vehicle,
422
+ charge_start_soc=current_soc,
423
+ terminus_deadtime=terminus_deadtime,
424
+ session=session,
425
+ )
393
426
 
394
427
 
395
428
  def generate_depot_layout(
@@ -555,22 +588,23 @@ def simulate_scenario(
555
588
  repetition_period: Optional[timedelta] = None,
556
589
  database_url: Optional[str] = None,
557
590
  smart_charging_strategy: SmartChargingStrategy = SmartChargingStrategy.EVEN,
591
+ ignore_unstable_simulation: bool = False,
558
592
  ) -> None:
559
593
  """
560
594
  This method simulates a scenario and adds the results to the database.
561
595
 
562
596
  It fills in the "Charging Events" in the :class:`eflips.model.Event` table and associates
563
597
  :class:`eflips.model.Vehicle` objects with all the existing "Driving Events" in the :class:`eflips.model.Event`
564
- table.
598
+ table. If the simulation becomes unstable, an :class:`UnstableSimulationException` is raised.
565
599
 
566
600
  :param scenario: Either a :class:`eflips.model.Scenario` object containing the input data for the simulation. Or
567
601
  an integer specifying the ID of a scenario in the database. Or any other object that has an attribute
568
602
  ``id`` that is an integer. If no :class:`eflips.model.Scenario` object is passed, the ``database_url``
569
- parameter must be set to a valid database URL ot the environment variable ``DATABASE_URL`` must be set to a
603
+ parameter must be set to a valid database URL or the environment variable ``DATABASE_URL`` must be set to a
570
604
  valid database URL.
571
605
 
572
606
  :param repetition_period: An optional timedelta object specifying the period of the vehicle schedules. This
573
- is needed because the result should be a steady-state result. THis can only be achieved by simulating a
607
+ is needed because the result should be a steady-state result. This can only be achieved by simulating a
574
608
  time period before and after our actual simulation, and then only using the "middle". eFLIPS tries to
575
609
  automatically detect whether the schedule should be repeated daily or weekly. If this fails, a ValueError is
576
610
  raised and repetition needs to be specified manually.
@@ -580,14 +614,22 @@ def simulate_scenario(
580
614
  URL.
581
615
 
582
616
  :param smart_charging_strategy: An optional parameter specifying the smart charging strategy to be used. The
583
- default is SmartChargingStragegy.NONE. The following strategies are available:
584
- - SmartChargingStragegy.NONE: Do not use smart charging. Buses are charged with the maximum power available,
617
+ default is SmartChargingStrategy.NONE. The following strategies are available:
618
+ - SmartChargingStrategy.NONE: Do not use smart charging. Buses are charged with the maximum power available,
585
619
  from the time they arrive at the depot until they are full (or leave the depot).
586
- - SmartChargingStragegy.EVEN: Use smart charging with an even distribution of charging power over the time the
620
+ - SmartChargingStrategy.EVEN: Use smart charging with an even distribution of charging power over the time the
587
621
  bus is at the depot. This aims to minimize the peak power demand.
622
+ - SmartChargingStrategy.MIN_PRICE: Not implemented yet.
623
+
624
+ :param ignore_unstable_simulation: If True, the simulation will not raise an exception if it becomes unstable.
588
625
 
589
626
  :return: Nothing. The results are added to the database.
627
+
628
+ :raises UnstableSimulationException: If the simulation becomes numerically unstable or if
629
+ the parameters cause the solver to diverge.
590
630
  """
631
+ logger = logging.getLogger(__name__)
632
+
591
633
  with create_session(scenario, database_url) as (session, scenario):
592
634
  simulation_host = init_simulation(
593
635
  scenario=scenario,
@@ -595,7 +637,13 @@ def simulate_scenario(
595
637
  repetition_period=repetition_period,
596
638
  )
597
639
  ev = run_simulation(simulation_host)
598
- add_evaluation_to_database(scenario, ev, session)
640
+ try:
641
+ add_evaluation_to_database(scenario, ev, session)
642
+ except eflips.depot.UnstableSimulationException as e:
643
+ if ignore_unstable_simulation:
644
+ logger.warning("Simulation is unstable. Continuing.")
645
+ else:
646
+ raise e
599
647
 
600
648
  match smart_charging_strategy:
601
649
  case SmartChargingStrategy.NONE:
@@ -842,7 +890,7 @@ def run_simulation(simulation_host: SimulationHost) -> Dict[str, DepotEvaluation
842
890
 
843
891
 
844
892
  def insert_dummy_standby_departure_events(
845
- depot_id: int, session: Session, sim_time_end: Optional[datetime.datetime] = None
893
+ depot_id: int, session: Session, sim_time_end: Optional[datetime] = None
846
894
  ) -> None:
847
895
  """
848
896
  Workaround for the missing STANDBY_DEPARTURE events in the database.
@@ -934,6 +982,9 @@ def add_evaluation_to_database(
934
982
  database.
935
983
 
936
984
  :return: Nothing. The results are added to the database.
985
+
986
+ :raises UnstableSimulationException: If the simulation becomes numerically unstable or if
987
+ the parameters cause the solver to diverge.
937
988
  """
938
989
 
939
990
  # Read simulation start time
@@ -0,0 +1,389 @@
1
+ import logging
2
+ from datetime import timedelta, datetime
3
+ from math import ceil
4
+ from typing import Tuple
5
+ from zoneinfo import ZoneInfo
6
+
7
+ import numpy as np
8
+ import sqlalchemy.orm
9
+ from eflips.model import (
10
+ Area,
11
+ Event,
12
+ EventType,
13
+ Rotation,
14
+ Vehicle,
15
+ Trip,
16
+ Station,
17
+ ChargeType,
18
+ )
19
+
20
+
21
+ def initialize_vehicle(rotation: Rotation, session: sqlalchemy.orm.session.Session):
22
+ """
23
+ Create and add a new Vehicle object in the database for the given rotation.
24
+
25
+ This function:
26
+ 1. Creates a new ``Vehicle`` instance using the provided rotation’s
27
+ vehicle type and scenario ID.
28
+ 2. Names it based on the rotation’s ID.
29
+ 3. Adds the vehicle to the specified SQLAlchemy session.
30
+ 4. Assigns the new vehicle to the rotation’s ``vehicle`` attribute.
31
+
32
+ :param rotation:
33
+ A :class:`Rotation` instance for which a new ``Vehicle`` should be created.
34
+ The new vehicle will inherit its type and scenario from this rotation.
35
+
36
+ :param session:
37
+ An active SQLAlchemy :class:`Session` used to persist the new vehicle to
38
+ the database. The vehicle is added to the session but not committed here.
39
+
40
+ :return:
41
+ ``None``. Changes are made to the session but are not committed yet.
42
+ """
43
+ vehicle = Vehicle(
44
+ vehicle_type_id=rotation.vehicle_type_id,
45
+ scenario_id=rotation.scenario_id,
46
+ name=f"Vehicle for rotation {rotation.id}",
47
+ )
48
+ session.add(vehicle)
49
+ rotation.vehicle = vehicle
50
+
51
+
52
+ def add_initial_standby_event(
53
+ vehicle: Vehicle, session: sqlalchemy.orm.session.Session
54
+ ):
55
+ """
56
+ Create and add a standby event immediately before the earliest trip of the given vehicle.
57
+
58
+ This function:
59
+ 1. Gathers all rotations assigned to the vehicle, sorted by their first trip’s departure time.
60
+ 2. Identifies the earliest trip across those rotations.
61
+ 3. Fetches an appropriate :class:`Area` record from the database based on
62
+ the vehicle's scenario and vehicle type (for depot and subloc capacity).
63
+ 4. Constructs a dummy standby event starting one second before the earliest trip’s
64
+ departure time, ending at the trip’s departure time, with 100% SoC.
65
+ 5. Adds the event to the session without committing (the caller is responsible for commits).
66
+
67
+ :param vehicle:
68
+ A :class:`Vehicle` instance for which to add a new standby event.
69
+ Must have associated rotations and trips.
70
+
71
+ :param session:
72
+ An active SQLAlchemy :class:`Session` used to persist the new event to
73
+ the database. The event is added to the session but not committed here.
74
+
75
+ :return:
76
+ ``None``. A new event is added to the session for the earliest trip,
77
+ but changes are not yet committed.
78
+ """
79
+ logger = logging.getLogger(__name__)
80
+
81
+ rotation_per_vehicle = sorted(
82
+ vehicle.rotations, key=lambda r: r.trips[0].departure_time
83
+ )
84
+
85
+ # Only keep the rotations that contain trips
86
+ rotation_per_vehicle = [r for r in rotation_per_vehicle if len(r.trips) > 0]
87
+ if len(rotation_per_vehicle) == 0:
88
+ logger.warning(f"No trips found for vehicle {vehicle.id}.")
89
+ return
90
+
91
+ earliest_trip = rotation_per_vehicle[0].trips[0]
92
+ area = (
93
+ session.query(Area)
94
+ .filter(Area.scenario_id == vehicle.scenario_id)
95
+ .filter(Area.vehicle_type_id == vehicle.vehicle_type_id)
96
+ .first()
97
+ )
98
+
99
+ standby_start = earliest_trip.departure_time - timedelta(seconds=1)
100
+ standby_event = Event(
101
+ scenario_id=vehicle.scenario_id,
102
+ vehicle_type_id=vehicle.vehicle_type_id,
103
+ vehicle=vehicle,
104
+ station_id=earliest_trip.route.departure_station_id,
105
+ subloc_no=0,
106
+ time_start=standby_start,
107
+ time_end=earliest_trip.departure_time,
108
+ soc_start=1,
109
+ soc_end=1,
110
+ event_type=EventType.STANDBY_DEPARTURE,
111
+ description=f"DUMMY Initial standby event for vehicle {vehicle.id}",
112
+ timeseries=None,
113
+ )
114
+ session.add(standby_event)
115
+
116
+
117
+ def find_charger_occupancy(
118
+ station: Station,
119
+ time_start: datetime,
120
+ time_end: datetime,
121
+ session: sqlalchemy.orm.session.Session,
122
+ resolution=timedelta(seconds=1),
123
+ ) -> Tuple[np.ndarray, np.ndarray]:
124
+ """
125
+ Build a timeseries of charger occupancy at a station between two points in time.
126
+
127
+ For each discrete timestep between ``time_start`` and ``time_end`` (at the given
128
+ ``resolution``), this function calculates how many charging events (from the database)
129
+ overlap with that time, thus producing a count of the active chargers at each timestep.
130
+
131
+ :param station:
132
+ The :class:`Station` whose charger occupancy is to be analyzed.
133
+ :param time_start:
134
+ The start time for the occupancy timeseries (inclusive).
135
+ :param time_end:
136
+ The end time for the occupancy timeseries (exclusive).
137
+ :param session:
138
+ An active SQLAlchemy :class:`Session` used to query the database.
139
+ :param resolution:
140
+ The timestep interval used to build the timeseries (default is 1 second).
141
+ Note that using a very fine resolution over a large time range can
142
+ produce large arrays.
143
+
144
+ :returns:
145
+ A tuple of two numpy arrays:
146
+ 1. ``times``: The array of discrete timesteps (shape: ``(n,)``).
147
+ 2. ``occupancy``: The array of integer occupancy values for each timestep
148
+ (shape: ``(n,)``), indicating how many charging events are active.
149
+ """
150
+ # Load all charging events that could be relevant
151
+ charging_events = (
152
+ session.query(Event)
153
+ .filter(
154
+ Event.station_id == station.id,
155
+ Event.time_start < time_end,
156
+ Event.time_end > time_start,
157
+ )
158
+ .all()
159
+ )
160
+
161
+ # We need to change the times to numpy datetime64 with implicit UTC timezone
162
+ tz = ZoneInfo("UTC")
163
+ time_start = np.datetime64(time_start.astimezone(tz).replace(tzinfo=None))
164
+ time_end = np.datetime64(time_end.astimezone(tz).replace(tzinfo=None))
165
+
166
+ times = np.arange(time_start, time_end, resolution)
167
+ occupancy = np.zeros_like(times, dtype=int)
168
+ for event in charging_events:
169
+ event_start = np.datetime64(
170
+ event.time_start.astimezone(tz).replace(tzinfo=None)
171
+ )
172
+ event_end = np.datetime64(event.time_end.astimezone(tz).replace(tzinfo=None))
173
+ start_idx = np.argmax(times >= event_start)
174
+ end_idx = np.argmax(times >= event_end)
175
+ occupancy[start_idx:end_idx] += 1
176
+
177
+ return times, occupancy
178
+
179
+
180
+ def find_best_timeslot(
181
+ station: Station,
182
+ time_start: datetime,
183
+ time_end: datetime,
184
+ charging_duration: timedelta,
185
+ session: sqlalchemy.orm.session.Session,
186
+ resolution: timedelta = timedelta(seconds=1),
187
+ ) -> datetime:
188
+ times, occupancy = find_charger_occupancy(
189
+ station, time_start, time_end, session, resolution=resolution
190
+ )
191
+
192
+ total_span = times[-1] - times[0]
193
+ if charging_duration > total_span:
194
+ raise ValueError("The event duration exceeds the entire timeseries span.")
195
+
196
+ ## AUTHOR: ChatGPT o-1
197
+ # Step 1: Compute how many indices are needed to cover `event_duration`.
198
+ steps_needed = int(charging_duration / resolution)
199
+ if steps_needed == 0:
200
+ raise ValueError("event_duration is too small for the timeseries resolution.")
201
+
202
+ # Step 2: Build a prefix-sum array for occupancy
203
+ prefix_sum = np.zeros(len(occupancy) + 1, dtype=float)
204
+ for i in range(len(occupancy)):
205
+ prefix_sum[i + 1] = prefix_sum[i] + occupancy[i]
206
+
207
+ # Step 3: Slide over every possible start index, compute sum in O(1)
208
+ best_start_idx = 0
209
+ min_sum = float("inf")
210
+ max_start_idx = len(occupancy) - steps_needed
211
+ if max_start_idx < 0:
212
+ raise ValueError("event_duration is too large for the timeseries resolution.")
213
+
214
+ for start_idx in range(max_start_idx + 1):
215
+ window_sum = prefix_sum[start_idx + steps_needed] - prefix_sum[start_idx]
216
+ if window_sum < min_sum:
217
+ min_sum = window_sum
218
+ best_start_idx = start_idx
219
+
220
+ best_start_time = times[best_start_idx]
221
+ # Turn it back into a datetime object with explicit UTC timezone
222
+ tz = ZoneInfo("UTC")
223
+ best_start_time = best_start_time.astype(datetime).replace(tzinfo=tz)
224
+
225
+ # Unused plot code to visually verify that it's working
226
+ if False:
227
+ # Convert numpy datetime array to matplotlib format
228
+ # If `times` is not numpy datetime64, you can skip this or adapt as needed.
229
+ # If `times` is a list of Python `datetime` objects, also skip the conversion step.
230
+ import matplotlib.pyplot as plt
231
+ import matplotlib.dates as mdates
232
+
233
+ fig, ax = plt.subplots(figsize=(10, 6))
234
+
235
+ # Plot the occupancy as a step or line plot
236
+ ax.plot(times, occupancy, label="Occupancy", drawstyle="steps-post", color="C0")
237
+
238
+ # Create a shaded region representing the best interval for the event
239
+ event_start = best_start_time
240
+ event_end = best_start_time + charging_duration
241
+ ax.axvspan(
242
+ event_start, event_end, color="C2", alpha=0.3, label="Chosen Interval"
243
+ )
244
+
245
+ # Format the x-axis to show date/time
246
+ # This only applies if your `times` are datetime objects or convertible to them
247
+ ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d %H:%M:%S"))
248
+ plt.xticks(rotation=45, ha="right")
249
+
250
+ ax.set_xlabel("Time")
251
+ ax.set_ylabel("Occupancy (# of events)")
252
+ ax.set_title("Charger Occupancy with Chosen Event Interval")
253
+ ax.legend()
254
+ ax.grid(True)
255
+
256
+ plt.tight_layout()
257
+ plt.show()
258
+
259
+ return best_start_time
260
+
261
+
262
+ def attempt_opportunity_charging_event(
263
+ previous_trip: Trip,
264
+ next_trip: Trip,
265
+ vehicle: Vehicle,
266
+ charge_start_soc: float,
267
+ terminus_deadtime: timedelta,
268
+ session: sqlalchemy.orm.session.Session,
269
+ ) -> float:
270
+ logger = logging.getLogger(__name__)
271
+
272
+ # Sanity checks
273
+ if previous_trip.route.arrival_station_id != next_trip.route.departure_station_id:
274
+ raise ValueError(
275
+ f"Trips {previous_trip.id} and {next_trip.id} are not consecutive."
276
+ )
277
+ if previous_trip.rotation_id != next_trip.rotation_id:
278
+ raise ValueError(
279
+ f"Trips {previous_trip.id} and {next_trip.id} are not in the same rotation."
280
+ )
281
+ if not (previous_trip.scenario_id == next_trip.scenario_id == vehicle.scenario_id):
282
+ raise ValueError(
283
+ f"Trips {previous_trip.id} and {next_trip.id} are not in the same scenario."
284
+ )
285
+ if not (
286
+ vehicle.vehicle_type.opportunity_charging_capable
287
+ and next_trip.rotation.allow_opportunity_charging
288
+ and previous_trip.route.arrival_station.is_electrified
289
+ and previous_trip.route.arrival_station.charge_type == ChargeType.OPPORTUNITY
290
+ ):
291
+ raise ValueError(
292
+ "Opportunity charging was requested even though it is not possible."
293
+ )
294
+
295
+ # Identify the break time between trips
296
+ break_time = next_trip.departure_time - previous_trip.arrival_time
297
+
298
+ if break_time > terminus_deadtime:
299
+ logger.debug(f"Adding opportunity charging event after trip {previous_trip.id}")
300
+
301
+ # How much energy can be charged in this time?
302
+ max_recharged_energy = (
303
+ max([v[1] for v in vehicle.vehicle_type.charging_curve])
304
+ * (break_time.total_seconds() - terminus_deadtime.total_seconds())
305
+ / 3600
306
+ )
307
+ needed_energy = (1 - charge_start_soc) * vehicle.vehicle_type.battery_capacity
308
+
309
+ if max_recharged_energy < needed_energy:
310
+ # We do not need to shift the time around. Just charge as much as possible
311
+ time_event_start = previous_trip.arrival_time
312
+ time_charge_start = time_event_start + terminus_deadtime / 2
313
+ time_charge_end = next_trip.departure_time - terminus_deadtime / 2
314
+ time_event_end = next_trip.departure_time
315
+
316
+ soc_event_start = charge_start_soc
317
+ soc_charge_start = charge_start_soc
318
+ soc_charge_end = (
319
+ charge_start_soc
320
+ + max_recharged_energy / vehicle.vehicle_type.battery_capacity
321
+ )
322
+ assert soc_charge_end <= 1
323
+ soc_event_end = soc_charge_end
324
+ else:
325
+ needed_duration_purely_charing = timedelta(
326
+ seconds=(
327
+ ceil(
328
+ needed_energy
329
+ * 3600
330
+ / max([v[1] for v in vehicle.vehicle_type.charging_curve])
331
+ )
332
+ )
333
+ )
334
+ needed_duration_total = needed_duration_purely_charing + terminus_deadtime
335
+
336
+ # We have to shift the time around to the time with the lowest occupancy
337
+ # Within this time band.
338
+
339
+ best_start_time = find_best_timeslot(
340
+ previous_trip.route.arrival_station,
341
+ previous_trip.arrival_time,
342
+ next_trip.departure_time,
343
+ needed_duration_total,
344
+ session,
345
+ )
346
+ time_event_start = best_start_time
347
+ time_charge_start = best_start_time + terminus_deadtime / 2
348
+ time_charge_end = time_charge_start + needed_duration_purely_charing
349
+ time_event_end = time_charge_end + (terminus_deadtime / 2)
350
+
351
+ soc_event_start = charge_start_soc
352
+ soc_charge_start = charge_start_soc
353
+ soc_charge_end = 1
354
+ soc_event_end = 1
355
+
356
+ # Create a simple timeseries for the charging event
357
+ timeseries = {
358
+ "time": [
359
+ time_event_start.isoformat(),
360
+ time_charge_start.isoformat(),
361
+ time_charge_end.isoformat(),
362
+ time_event_end.isoformat(),
363
+ ],
364
+ "soc": [soc_event_start, soc_charge_start, soc_charge_end, soc_event_end],
365
+ }
366
+
367
+ # Create the charging event
368
+ current_event = Event(
369
+ scenario_id=vehicle.scenario_id,
370
+ vehicle_type_id=vehicle.vehicle_type_id,
371
+ vehicle=vehicle,
372
+ station_id=previous_trip.route.arrival_station_id,
373
+ time_start=time_event_start,
374
+ time_end=time_event_end,
375
+ soc_start=charge_start_soc,
376
+ soc_end=soc_event_end,
377
+ event_type=EventType.CHARGING_OPPORTUNITY,
378
+ description=f"Opportunity charging event after trip {previous_trip.id}.",
379
+ timeseries=timeseries,
380
+ )
381
+ session.add(current_event)
382
+ session.flush()
383
+ return soc_event_end
384
+
385
+ else:
386
+ logger.debug(
387
+ f"No opportunity charging event added after trip {previous_trip.id}"
388
+ )
389
+ return charge_start_soc
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "eflips-depot"
3
- version = "4.4.3"
3
+ version = "4.6.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