eflips-depot 4.5.0__py3-none-any.whl → 4.6.1__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.
Potentially problematic release.
This version of eflips-depot might be problematic. Click here for more details.
- eflips/depot/api/__init__.py +27 -161
- eflips/depot/api/private/consumption.py +426 -0
- {eflips_depot-4.5.0.dist-info → eflips_depot-4.6.1.dist-info}/METADATA +1 -1
- {eflips_depot-4.5.0.dist-info → eflips_depot-4.6.1.dist-info}/RECORD +6 -5
- {eflips_depot-4.5.0.dist-info → eflips_depot-4.6.1.dist-info}/LICENSE.md +0 -0
- {eflips_depot-4.5.0.dist-info → eflips_depot-4.6.1.dist-info}/WHEEL +0 -0
eflips/depot/api/__init__.py
CHANGED
|
@@ -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,
|
|
@@ -246,76 +252,18 @@ def simple_consumption_simulation(
|
|
|
246
252
|
)
|
|
247
253
|
if initialize_vehicles:
|
|
248
254
|
for rotation in rotations:
|
|
249
|
-
|
|
250
|
-
vehicle_type_id=rotation.vehicle_type_id,
|
|
251
|
-
scenario_id=scenario.id,
|
|
252
|
-
name=f"Vehicle for rotation {rotation.id}",
|
|
253
|
-
)
|
|
254
|
-
session.add(vehicle)
|
|
255
|
-
rotation.vehicle = vehicle
|
|
255
|
+
initialize_vehicle(rotation, session)
|
|
256
256
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
standby_event = Event(
|
|
261
|
-
scenario_id=scenario.id,
|
|
262
|
-
vehicle_type_id=rotation.vehicle_type_id,
|
|
263
|
-
vehicle=vehicle,
|
|
264
|
-
station_id=rotation.trips[0].route.departure_station_id,
|
|
265
|
-
subloc_no=0,
|
|
266
|
-
time_start=standby_start,
|
|
267
|
-
time_end=first_trip_start,
|
|
268
|
-
soc_start=1,
|
|
269
|
-
soc_end=1,
|
|
270
|
-
event_type=EventType.CHARGING_OPPORTUNITY,
|
|
271
|
-
description=f"DUMMY Initial standby event for rotation {rotation.id}.",
|
|
272
|
-
timeseries=None,
|
|
273
|
-
)
|
|
274
|
-
session.add(standby_event)
|
|
275
|
-
else:
|
|
276
|
-
for rotation in rotations:
|
|
277
|
-
if rotation.vehicle is None:
|
|
278
|
-
raise ValueError(
|
|
279
|
-
"The rotation does not have a vehicle assigned to it."
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
vehicles = (
|
|
283
|
-
session.query(Vehicle).filter(Vehicle.scenario_id == scenario.id).all()
|
|
284
|
-
)
|
|
285
|
-
for vehicle in vehicles:
|
|
286
|
-
if (
|
|
287
|
-
session.query(Event).filter(Event.vehicle_id == vehicle.id).count()
|
|
288
|
-
== 0
|
|
289
|
-
):
|
|
290
|
-
# Also add a dummy standby-departure event if this vehicle has no events
|
|
291
|
-
rotation_per_vehicle = sorted(
|
|
292
|
-
vehicle.rotations, key=lambda r: r.trips[0].departure_time
|
|
293
|
-
)
|
|
294
|
-
earliest_trip = rotation_per_vehicle[0].trips[0]
|
|
295
|
-
area = (
|
|
296
|
-
session.query(Area)
|
|
297
|
-
.filter(Area.scenario_id == scenario.id)
|
|
298
|
-
.filter(Area.vehicle_type_id == vehicle.vehicle_type_id)
|
|
299
|
-
.first()
|
|
300
|
-
)
|
|
257
|
+
for rotation in rotations:
|
|
258
|
+
if rotation.vehicle is None:
|
|
259
|
+
raise ValueError("The rotation does not have a vehicle assigned to it.")
|
|
301
260
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
area_id=area.id,
|
|
309
|
-
subloc_no=area.capacity,
|
|
310
|
-
time_start=standby_start,
|
|
311
|
-
time_end=earliest_trip.departure_time,
|
|
312
|
-
soc_start=1,
|
|
313
|
-
soc_end=1,
|
|
314
|
-
event_type=EventType.STANDBY_DEPARTURE,
|
|
315
|
-
description=f"DUMMY Initial standby event for rotation {earliest_trip.rotation_id}.",
|
|
316
|
-
timeseries=None,
|
|
317
|
-
)
|
|
318
|
-
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)
|
|
319
267
|
|
|
320
268
|
# Since we are doing no_autoflush blocks later, we need to flush the session once here so that unflushed stuff
|
|
321
269
|
# From preceding functions is visible in the database
|
|
@@ -361,7 +309,7 @@ def simple_consumption_simulation(
|
|
|
361
309
|
for trip in rotation.trips:
|
|
362
310
|
# Set up a timeseries
|
|
363
311
|
if consumption_result is None or trip.id not in consumption_result:
|
|
364
|
-
logger.
|
|
312
|
+
logger.debug("Calculating consumption for trip %s", trip.id)
|
|
365
313
|
soc_start = current_soc
|
|
366
314
|
if calculate_timeseries and len(trip.stop_times) > 0:
|
|
367
315
|
timeseries = {
|
|
@@ -398,7 +346,7 @@ def simple_consumption_simulation(
|
|
|
398
346
|
soc_start - energy_used / vehicle_type.battery_capacity
|
|
399
347
|
)
|
|
400
348
|
else:
|
|
401
|
-
logger.
|
|
349
|
+
logger.debug(f"Using pre-calculated timeseries for trip {trip.id}")
|
|
402
350
|
if (
|
|
403
351
|
calculate_timeseries
|
|
404
352
|
and consumption_result[trip.id].timestamps is not None
|
|
@@ -461,102 +409,20 @@ def simple_consumption_simulation(
|
|
|
461
409
|
rotation.vehicle_type.opportunity_charging_capable
|
|
462
410
|
and rotation.allow_opportunity_charging
|
|
463
411
|
and trip.route.arrival_station.is_electrified
|
|
412
|
+
and trip.route.arrival_station.charge_type == ChargeType.OPPORTUNITY
|
|
464
413
|
and trip != rotation.trips[-1]
|
|
465
414
|
):
|
|
466
|
-
logger.debug(
|
|
467
|
-
f"Adding opportunity charging event for trip {trip.id}"
|
|
468
|
-
)
|
|
469
|
-
# Identify the break time between trips
|
|
470
415
|
trip_index = rotation.trips.index(trip)
|
|
471
416
|
next_trip = rotation.trips[trip_index + 1]
|
|
472
|
-
break_time = next_trip.departure_time - trip.arrival_time
|
|
473
|
-
|
|
474
|
-
# How much energy can be charged in this time?
|
|
475
|
-
energy_charged = (
|
|
476
|
-
max([v[1] for v in vehicle_type.charging_curve])
|
|
477
|
-
* (
|
|
478
|
-
break_time.total_seconds()
|
|
479
|
-
- terminus_deadtime.total_seconds()
|
|
480
|
-
)
|
|
481
|
-
/ 3600
|
|
482
|
-
)
|
|
483
|
-
|
|
484
|
-
if energy_charged > 0:
|
|
485
|
-
# Calculate the end SoC
|
|
486
|
-
post_charge_soc = min(
|
|
487
|
-
current_soc
|
|
488
|
-
+ energy_charged / vehicle_type.battery_capacity,
|
|
489
|
-
1,
|
|
490
|
-
)
|
|
491
|
-
|
|
492
|
-
# If the post_charge_soc is 1, calculate when the vehicle was full
|
|
493
|
-
if post_charge_soc == 1:
|
|
494
|
-
# 1. Get the max charging power (kW)
|
|
495
|
-
max_power = max([v[1] for v in vehicle_type.charging_curve])
|
|
496
|
-
|
|
497
|
-
# 2. Energy needed (kWh) to go from current_soc to 100%
|
|
498
|
-
energy_needed_kWh = (
|
|
499
|
-
1 - current_soc
|
|
500
|
-
) * vehicle_type.battery_capacity
|
|
501
|
-
|
|
502
|
-
# 3. Compute how long that takes at max_power (in hours)
|
|
503
|
-
time_needed_hours = energy_needed_kWh / max_power
|
|
504
|
-
|
|
505
|
-
# 4. Calculate the point in time the vehicle became full
|
|
506
|
-
# If charging effectively starts right after terminus_deadtime
|
|
507
|
-
time_full = (
|
|
508
|
-
trip.arrival_time
|
|
509
|
-
+ terminus_deadtime / 2
|
|
510
|
-
+ timedelta(hours=time_needed_hours)
|
|
511
|
-
)
|
|
512
417
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
# Create a simple timeseries for the charging event
|
|
522
|
-
timeseries = {
|
|
523
|
-
"time": [
|
|
524
|
-
trip.arrival_time.isoformat(),
|
|
525
|
-
(trip.arrival_time + terminus_deadtime / 2).isoformat(),
|
|
526
|
-
(
|
|
527
|
-
next_trip.departure_time - terminus_deadtime / 2
|
|
528
|
-
).isoformat(),
|
|
529
|
-
next_trip.departure_time.isoformat(),
|
|
530
|
-
],
|
|
531
|
-
"soc": [
|
|
532
|
-
current_soc,
|
|
533
|
-
current_soc,
|
|
534
|
-
post_charge_soc,
|
|
535
|
-
post_charge_soc,
|
|
536
|
-
],
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
# If time_full is not None, add it to the timeseries in the middle
|
|
540
|
-
if time_full is not None:
|
|
541
|
-
timeseries["time"].insert(2, time_full.isoformat())
|
|
542
|
-
timeseries["soc"].insert(2, 1)
|
|
543
|
-
|
|
544
|
-
# Create the charging event
|
|
545
|
-
current_event = Event(
|
|
546
|
-
scenario_id=scenario.id,
|
|
547
|
-
vehicle_type_id=rotation.vehicle_type_id,
|
|
548
|
-
vehicle=vehicle,
|
|
549
|
-
station_id=trip.route.arrival_station_id,
|
|
550
|
-
time_start=trip.arrival_time,
|
|
551
|
-
time_end=next_trip.departure_time,
|
|
552
|
-
soc_start=current_soc,
|
|
553
|
-
soc_end=post_charge_soc,
|
|
554
|
-
event_type=EventType.CHARGING_OPPORTUNITY,
|
|
555
|
-
description=f"Opportunity charging event for trip {trip.id}.",
|
|
556
|
-
timeseries=timeseries,
|
|
557
|
-
)
|
|
558
|
-
current_soc = post_charge_soc
|
|
559
|
-
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
|
+
)
|
|
560
426
|
|
|
561
427
|
|
|
562
428
|
def generate_depot_layout(
|
|
@@ -0,0 +1,426 @@
|
|
|
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.event_type == EventType.CHARGING_OPPORTUNITY,
|
|
155
|
+
Event.station_id == station.id,
|
|
156
|
+
Event.time_start < time_end,
|
|
157
|
+
Event.time_end > time_start,
|
|
158
|
+
)
|
|
159
|
+
.all()
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# We need to change the times to numpy datetime64 with implicit UTC timezone
|
|
163
|
+
tz = ZoneInfo("UTC")
|
|
164
|
+
time_start = np.datetime64(time_start.astimezone(tz).replace(tzinfo=None))
|
|
165
|
+
time_end = np.datetime64(time_end.astimezone(tz).replace(tzinfo=None))
|
|
166
|
+
|
|
167
|
+
times = np.arange(time_start, time_end, resolution)
|
|
168
|
+
occupancy = np.zeros_like(times, dtype=int)
|
|
169
|
+
for event in charging_events:
|
|
170
|
+
event_start = np.datetime64(
|
|
171
|
+
event.time_start.astimezone(tz).replace(tzinfo=None)
|
|
172
|
+
)
|
|
173
|
+
event_end = np.datetime64(event.time_end.astimezone(tz).replace(tzinfo=None))
|
|
174
|
+
start_idx = np.argmax(times >= event_start)
|
|
175
|
+
end_idx = np.argmax(times >= event_end)
|
|
176
|
+
occupancy[start_idx:end_idx] += 1
|
|
177
|
+
|
|
178
|
+
return times, occupancy
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def find_best_timeslot(
|
|
182
|
+
station: Station,
|
|
183
|
+
time_start: datetime,
|
|
184
|
+
time_end: datetime,
|
|
185
|
+
charging_duration: timedelta,
|
|
186
|
+
session: sqlalchemy.orm.session.Session,
|
|
187
|
+
resolution: timedelta = timedelta(seconds=1),
|
|
188
|
+
) -> datetime:
|
|
189
|
+
times, occupancy = find_charger_occupancy(
|
|
190
|
+
station, time_start, time_end, session, resolution=resolution
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
total_span = times[-1] - times[0]
|
|
194
|
+
if charging_duration > total_span:
|
|
195
|
+
raise ValueError("The event duration exceeds the entire timeseries span.")
|
|
196
|
+
|
|
197
|
+
## AUTHOR: ChatGPT o-1
|
|
198
|
+
# Step 1: Compute how many indices are needed to cover `event_duration`.
|
|
199
|
+
steps_needed = int(charging_duration / resolution)
|
|
200
|
+
if steps_needed == 0:
|
|
201
|
+
raise ValueError("event_duration is too small for the timeseries resolution.")
|
|
202
|
+
|
|
203
|
+
# Step 2: Build a prefix-sum array for occupancy
|
|
204
|
+
prefix_sum = np.zeros(len(occupancy) + 1, dtype=float)
|
|
205
|
+
for i in range(len(occupancy)):
|
|
206
|
+
prefix_sum[i + 1] = prefix_sum[i] + occupancy[i]
|
|
207
|
+
|
|
208
|
+
# Step 3: Slide over every possible start index, compute sum in O(1)
|
|
209
|
+
best_start_idx = 0
|
|
210
|
+
min_sum = float("inf")
|
|
211
|
+
max_start_idx = len(occupancy) - steps_needed
|
|
212
|
+
if max_start_idx < 0:
|
|
213
|
+
raise ValueError("event_duration is too large for the timeseries resolution.")
|
|
214
|
+
|
|
215
|
+
for start_idx in range(max_start_idx + 1):
|
|
216
|
+
window_sum = prefix_sum[start_idx + steps_needed] - prefix_sum[start_idx]
|
|
217
|
+
if window_sum < min_sum:
|
|
218
|
+
min_sum = window_sum
|
|
219
|
+
best_start_idx = start_idx
|
|
220
|
+
|
|
221
|
+
best_start_time = times[best_start_idx]
|
|
222
|
+
# Turn it back into a datetime object with explicit UTC timezone
|
|
223
|
+
tz = ZoneInfo("UTC")
|
|
224
|
+
best_start_time = best_start_time.astype(datetime).replace(tzinfo=tz)
|
|
225
|
+
|
|
226
|
+
# Unused plot code to visually verify that it's working
|
|
227
|
+
if False:
|
|
228
|
+
# Convert numpy datetime array to matplotlib format
|
|
229
|
+
# If `times` is not numpy datetime64, you can skip this or adapt as needed.
|
|
230
|
+
# If `times` is a list of Python `datetime` objects, also skip the conversion step.
|
|
231
|
+
import matplotlib.pyplot as plt
|
|
232
|
+
import matplotlib.dates as mdates
|
|
233
|
+
|
|
234
|
+
fig, ax = plt.subplots(figsize=(10, 6))
|
|
235
|
+
|
|
236
|
+
# Plot the occupancy as a step or line plot
|
|
237
|
+
ax.plot(times, occupancy, label="Occupancy", drawstyle="steps-post", color="C0")
|
|
238
|
+
|
|
239
|
+
# Create a shaded region representing the best interval for the event
|
|
240
|
+
event_start = best_start_time
|
|
241
|
+
event_end = best_start_time + charging_duration
|
|
242
|
+
ax.axvspan(
|
|
243
|
+
event_start, event_end, color="C2", alpha=0.3, label="Chosen Interval"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Format the x-axis to show date/time
|
|
247
|
+
# This only applies if your `times` are datetime objects or convertible to them
|
|
248
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d %H:%M:%S"))
|
|
249
|
+
plt.xticks(rotation=45, ha="right")
|
|
250
|
+
|
|
251
|
+
ax.set_xlabel("Time")
|
|
252
|
+
ax.set_ylabel("Occupancy (# of events)")
|
|
253
|
+
ax.set_title("Charger Occupancy with Chosen Event Interval")
|
|
254
|
+
ax.legend()
|
|
255
|
+
ax.grid(True)
|
|
256
|
+
|
|
257
|
+
plt.tight_layout()
|
|
258
|
+
plt.show()
|
|
259
|
+
|
|
260
|
+
return best_start_time
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def attempt_opportunity_charging_event(
|
|
264
|
+
previous_trip: Trip,
|
|
265
|
+
next_trip: Trip,
|
|
266
|
+
vehicle: Vehicle,
|
|
267
|
+
charge_start_soc: float,
|
|
268
|
+
terminus_deadtime: timedelta,
|
|
269
|
+
session: sqlalchemy.orm.session.Session,
|
|
270
|
+
) -> float:
|
|
271
|
+
logger = logging.getLogger(__name__)
|
|
272
|
+
|
|
273
|
+
# Sanity checks
|
|
274
|
+
if previous_trip.route.arrival_station_id != next_trip.route.departure_station_id:
|
|
275
|
+
raise ValueError(
|
|
276
|
+
f"Trips {previous_trip.id} and {next_trip.id} are not consecutive."
|
|
277
|
+
)
|
|
278
|
+
if previous_trip.rotation_id != next_trip.rotation_id:
|
|
279
|
+
raise ValueError(
|
|
280
|
+
f"Trips {previous_trip.id} and {next_trip.id} are not in the same rotation."
|
|
281
|
+
)
|
|
282
|
+
if not (previous_trip.scenario_id == next_trip.scenario_id == vehicle.scenario_id):
|
|
283
|
+
raise ValueError(
|
|
284
|
+
f"Trips {previous_trip.id} and {next_trip.id} are not in the same scenario."
|
|
285
|
+
)
|
|
286
|
+
if not (
|
|
287
|
+
vehicle.vehicle_type.opportunity_charging_capable
|
|
288
|
+
and next_trip.rotation.allow_opportunity_charging
|
|
289
|
+
and previous_trip.route.arrival_station.is_electrified
|
|
290
|
+
and previous_trip.route.arrival_station.charge_type == ChargeType.OPPORTUNITY
|
|
291
|
+
):
|
|
292
|
+
raise ValueError(
|
|
293
|
+
"Opportunity charging was requested even though it is not possible."
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Identify the break time between trips
|
|
297
|
+
break_time = next_trip.departure_time - previous_trip.arrival_time
|
|
298
|
+
|
|
299
|
+
if break_time > terminus_deadtime:
|
|
300
|
+
logger.debug(f"Adding opportunity charging event after trip {previous_trip.id}")
|
|
301
|
+
|
|
302
|
+
# How much energy can be charged in this time?
|
|
303
|
+
max_recharged_energy = (
|
|
304
|
+
max([v[1] for v in vehicle.vehicle_type.charging_curve])
|
|
305
|
+
* (break_time.total_seconds() - terminus_deadtime.total_seconds())
|
|
306
|
+
/ 3600
|
|
307
|
+
)
|
|
308
|
+
needed_energy = (1 - charge_start_soc) * vehicle.vehicle_type.battery_capacity
|
|
309
|
+
|
|
310
|
+
if max_recharged_energy < needed_energy:
|
|
311
|
+
# We do not need to shift the time around. Just charge as much as possible
|
|
312
|
+
time_event_start = previous_trip.arrival_time
|
|
313
|
+
time_charge_start = time_event_start + terminus_deadtime / 2
|
|
314
|
+
time_charge_end = next_trip.departure_time - terminus_deadtime / 2
|
|
315
|
+
time_event_end = next_trip.departure_time
|
|
316
|
+
|
|
317
|
+
soc_event_start = charge_start_soc
|
|
318
|
+
soc_charge_start = charge_start_soc
|
|
319
|
+
soc_charge_end = (
|
|
320
|
+
charge_start_soc
|
|
321
|
+
+ max_recharged_energy / vehicle.vehicle_type.battery_capacity
|
|
322
|
+
)
|
|
323
|
+
assert soc_charge_end <= 1
|
|
324
|
+
soc_event_end = soc_charge_end
|
|
325
|
+
else:
|
|
326
|
+
needed_duration_purely_charing = timedelta(
|
|
327
|
+
seconds=(
|
|
328
|
+
ceil(
|
|
329
|
+
needed_energy
|
|
330
|
+
* 3600
|
|
331
|
+
/ max([v[1] for v in vehicle.vehicle_type.charging_curve])
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
needed_duration_total = needed_duration_purely_charing + terminus_deadtime
|
|
336
|
+
|
|
337
|
+
# We have to shift the time around to the time with the lowest occupancy
|
|
338
|
+
# Within this time band.
|
|
339
|
+
|
|
340
|
+
best_start_time = find_best_timeslot(
|
|
341
|
+
previous_trip.route.arrival_station,
|
|
342
|
+
previous_trip.arrival_time,
|
|
343
|
+
next_trip.departure_time,
|
|
344
|
+
needed_duration_total,
|
|
345
|
+
session,
|
|
346
|
+
)
|
|
347
|
+
time_event_start = best_start_time
|
|
348
|
+
time_charge_start = best_start_time + terminus_deadtime / 2
|
|
349
|
+
time_charge_end = time_charge_start + needed_duration_purely_charing
|
|
350
|
+
time_event_end = time_charge_end + (terminus_deadtime / 2)
|
|
351
|
+
|
|
352
|
+
soc_event_start = charge_start_soc
|
|
353
|
+
soc_charge_start = charge_start_soc
|
|
354
|
+
soc_charge_end = 1
|
|
355
|
+
soc_event_end = 1
|
|
356
|
+
|
|
357
|
+
# Create a simple timeseries for the charging event
|
|
358
|
+
timeseries = {
|
|
359
|
+
"time": [
|
|
360
|
+
time_event_start.isoformat(),
|
|
361
|
+
time_charge_start.isoformat(),
|
|
362
|
+
time_charge_end.isoformat(),
|
|
363
|
+
time_event_end.isoformat(),
|
|
364
|
+
],
|
|
365
|
+
"soc": [soc_event_start, soc_charge_start, soc_charge_end, soc_event_end],
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
# Create the charging event
|
|
369
|
+
current_event = Event(
|
|
370
|
+
scenario_id=vehicle.scenario_id,
|
|
371
|
+
vehicle_type_id=vehicle.vehicle_type_id,
|
|
372
|
+
vehicle=vehicle,
|
|
373
|
+
station_id=previous_trip.route.arrival_station_id,
|
|
374
|
+
time_start=time_event_start,
|
|
375
|
+
time_end=time_event_end,
|
|
376
|
+
soc_start=charge_start_soc,
|
|
377
|
+
soc_end=soc_event_end,
|
|
378
|
+
event_type=EventType.CHARGING_OPPORTUNITY,
|
|
379
|
+
description=f"Opportunity charging event after trip {previous_trip.id}.",
|
|
380
|
+
timeseries=timeseries,
|
|
381
|
+
)
|
|
382
|
+
session.add(current_event)
|
|
383
|
+
|
|
384
|
+
# If there is time between the previous trip's end and the charging event's start, add a STANDBY event
|
|
385
|
+
if time_event_start > previous_trip.arrival_time:
|
|
386
|
+
standby_event = Event(
|
|
387
|
+
scenario_id=vehicle.scenario_id,
|
|
388
|
+
vehicle_type_id=vehicle.vehicle_type_id,
|
|
389
|
+
vehicle=vehicle,
|
|
390
|
+
station_id=previous_trip.route.arrival_station_id,
|
|
391
|
+
time_start=previous_trip.arrival_time,
|
|
392
|
+
time_end=time_event_start,
|
|
393
|
+
soc_start=charge_start_soc, # SoC is unchanged while in STANDBY
|
|
394
|
+
soc_end=charge_start_soc,
|
|
395
|
+
event_type=EventType.STANDBY,
|
|
396
|
+
description=f"Standby event before charging after trip {previous_trip.id}.",
|
|
397
|
+
timeseries=None,
|
|
398
|
+
)
|
|
399
|
+
session.add(standby_event)
|
|
400
|
+
|
|
401
|
+
# If there is time between the charging event's end and the next trip's start, add a STANDBY_DEPARTURE event
|
|
402
|
+
if time_event_end < next_trip.departure_time:
|
|
403
|
+
standby_departure_event = Event(
|
|
404
|
+
scenario_id=vehicle.scenario_id,
|
|
405
|
+
vehicle_type_id=vehicle.vehicle_type_id,
|
|
406
|
+
vehicle=vehicle,
|
|
407
|
+
station_id=previous_trip.route.arrival_station_id,
|
|
408
|
+
time_start=time_event_end,
|
|
409
|
+
time_end=next_trip.departure_time,
|
|
410
|
+
soc_start=soc_event_end, # SoC is unchanged while in STANDBY
|
|
411
|
+
soc_end=soc_event_end,
|
|
412
|
+
event_type=EventType.STANDBY_DEPARTURE,
|
|
413
|
+
description=(
|
|
414
|
+
f"Standby departure event after charging, before trip {next_trip.id}."
|
|
415
|
+
),
|
|
416
|
+
timeseries=None,
|
|
417
|
+
)
|
|
418
|
+
session.add(standby_departure_event)
|
|
419
|
+
|
|
420
|
+
return soc_event_end
|
|
421
|
+
|
|
422
|
+
else:
|
|
423
|
+
logger.debug(
|
|
424
|
+
f"No opportunity charging event added after trip {previous_trip.id}"
|
|
425
|
+
)
|
|
426
|
+
return charge_start_soc
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
eflips/depot/__init__.py,sha256=RQ_UKNrGWA6q17TZFu86ai8pC7qCpcbmAgVKh7aImwo,1613
|
|
2
|
-
eflips/depot/api/__init__.py,sha256=
|
|
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=WIsXTPSC-GEnzzAeojHv80ylosdKghYjZp0-HAKIsto,16593
|
|
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.1.dist-info/LICENSE.md,sha256=KB4XTk1fPHjtZCYDyPyreu6h1LVJVZXYg-5vePcWZAc,34143
|
|
41
|
+
eflips_depot-4.6.1.dist-info/METADATA,sha256=NE2AQv4V2D92MnSOVSNP4doVRPO1aCSYVmqezpQnggM,5940
|
|
42
|
+
eflips_depot-4.6.1.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
43
|
+
eflips_depot-4.6.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|