eflips-depot 4.4.3__py3-none-any.whl → 4.6.0__py3-none-any.whl
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.
- eflips/depot/__init__.py +4 -0
- eflips/depot/api/__init__.py +249 -198
- eflips/depot/api/private/consumption.py +389 -0
- {eflips_depot-4.4.3.dist-info → eflips_depot-4.6.0.dist-info}/METADATA +3 -3
- {eflips_depot-4.4.3.dist-info → eflips_depot-4.6.0.dist-info}/RECORD +7 -6
- {eflips_depot-4.4.3.dist-info → eflips_depot-4.6.0.dist-info}/WHEEL +1 -1
- {eflips_depot-4.4.3.dist-info → eflips_depot-4.6.0.dist-info}/LICENSE.md +0 -0
eflips/depot/__init__.py
CHANGED
|
@@ -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
|
eflips/depot/api/__init__.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
``VehicleType.consumption
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
i
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
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.
|
|
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
|
|
584
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
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,8 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: eflips-depot
|
|
3
|
-
Version: 4.
|
|
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
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
eflips/depot/__init__.py,sha256=
|
|
2
|
-
eflips/depot/api/__init__.py,sha256=
|
|
1
|
+
eflips/depot/__init__.py,sha256=RQ_UKNrGWA6q17TZFu86ai8pC7qCpcbmAgVKh7aImwo,1613
|
|
2
|
+
eflips/depot/api/__init__.py,sha256=Sxsqj5qIcSK-dqtE2UqaHVWEWnycU65N-pDnLuTJfeU,48244
|
|
3
3
|
eflips/depot/api/defaults/default_settings.json,sha256=0eUDTw_rtLQFvthP8oJL93iRXlmAOravAg-4qqGMQAY,5375
|
|
4
4
|
eflips/depot/api/private/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
eflips/depot/api/private/consumption.py,sha256=EbWuQnCXVXU3jAPbcig4OunZBJ6-pUCtVwPwWssjf8E,14769
|
|
5
6
|
eflips/depot/api/private/depot.py,sha256=v5Edb0sQP2QNIyBLvccVzAK9_kxCszar0cOu5ciFyrw,40780
|
|
6
7
|
eflips/depot/api/private/results_to_database.py,sha256=Sh2VJ3k60QJ5RGkc8sw-7XbljiMe65EHeoagKIpYlHM,24585
|
|
7
8
|
eflips/depot/api/private/smart_charging.py,sha256=MQ9fXdKByHAz6RSKXYcpJXDBccdJKZ2qGReCHagVCyo,13033
|
|
@@ -36,7 +37,7 @@ eflips/depot/simulation.py,sha256=ee0qTzOzG-8ybN36ie_NJallXfC7jUaS9JZvaYFziLs,10
|
|
|
36
37
|
eflips/depot/smart_charging.py,sha256=C3BYqzn2-OYY4ipXm0ETtavbAM9QXZMYULBpVoChf0E,54311
|
|
37
38
|
eflips/depot/standalone.py,sha256=VxcTzBaB67fNJUMmjPRwKXjhqTy6oQ41Coote2LvAmk,22338
|
|
38
39
|
eflips/depot/validation.py,sha256=TIuY7cQtEJI4H2VVMSuY5IIVkacEEZ67weeMuY3NSAM,7097
|
|
39
|
-
eflips_depot-4.
|
|
40
|
-
eflips_depot-4.
|
|
41
|
-
eflips_depot-4.
|
|
42
|
-
eflips_depot-4.
|
|
40
|
+
eflips_depot-4.6.0.dist-info/LICENSE.md,sha256=KB4XTk1fPHjtZCYDyPyreu6h1LVJVZXYg-5vePcWZAc,34143
|
|
41
|
+
eflips_depot-4.6.0.dist-info/METADATA,sha256=yWVUD74eqK640IbC6LoH_Ko0INybTuxU3KEuBqP3LjY,5940
|
|
42
|
+
eflips_depot-4.6.0.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
43
|
+
eflips_depot-4.6.0.dist-info/RECORD,,
|
|
File without changes
|