eflips-eval 0.1.2__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.
@@ -0,0 +1,86 @@
1
+ from datetime import datetime
2
+ from typing import Dict, List
3
+
4
+ import sqlalchemy
5
+ import pandas as pd
6
+
7
+ from eflips.model import Rotation
8
+
9
+
10
+ def rotation_name_for_sorting(rotation_name: str) -> str:
11
+ """
12
+ Takes a rotation name, which in the BVG is a string of two numbers separated by a '/' character, and returns a string
13
+ that can be used for sorting. The first part of the string is returned for BVG rotations.
14
+
15
+ Other rotation names are not supported and will return the rotation name itself.
16
+
17
+ :param rotation_name: The rotation name
18
+ :return: a sortable string
19
+ """
20
+
21
+ if rotation_name is not None and "/" in rotation_name:
22
+ return rotation_name.split("/")[0]
23
+ else:
24
+ return rotation_name
25
+
26
+
27
+ def rotation_info(
28
+ scenario_id: int,
29
+ session: sqlalchemy.orm.session.Session,
30
+ rotation_ids: None | int | List[int] = None,
31
+ ) -> pd.DataFrame:
32
+ """
33
+ This function provides information about the rotations in a scenario. This information can be provided even before
34
+ the simulation has been run. It creates a dataframe with the following columns:
35
+
36
+ - rotation_id: the id of the rotation
37
+ - rotation_name: the name of the rotation
38
+ - vehicle_type_id: the id of the vehicle type
39
+ - vehicle_type_name: the name of the vehicle type
40
+ - total_distance: the total distance of the rotation
41
+ - time_start: the departure of the first trip
42
+ - time_end: the arrival of the last trip
43
+ - line_name: the name of the line, which is the first part of the rotation name. Used for sorting
44
+
45
+ :param scenario_id: The scenario id for which to create the dataframe
46
+ :param session: An sqlalchemy session to an eflips-model database
47
+ :param rotation_ids: A list of rotation ids to filter for. If None, all rotations are included
48
+ :return: a pandas DataFrame
49
+ """
50
+
51
+ result: List[Dict[str, int | float | str | datetime]] = []
52
+
53
+ rotations = session.query(Rotation).filter(Rotation.scenario_id == scenario_id)
54
+
55
+ if rotation_ids is not None:
56
+ if isinstance(rotation_ids, int):
57
+ rotation_ids = [rotation_ids]
58
+ rotations = rotations.filter(Rotation.id.in_(rotation_ids))
59
+
60
+ for rotation in rotations:
61
+ # The rotation distance comes form the routes of the trips
62
+ distance = 0.0
63
+ for trip in rotation.trips:
64
+ distance += trip.route.distance / 1000
65
+
66
+ result.append(
67
+ {
68
+ "rotation_id": rotation.id,
69
+ "rotation_name": rotation.name,
70
+ "vehicle_type_id": rotation.vehicle_type_id,
71
+ "vehicle_type_name": rotation.vehicle_type.name,
72
+ "total_distance": distance,
73
+ "time_start": rotation.trips[0].departure_time,
74
+ "time_end": rotation.trips[-1].arrival_time,
75
+ }
76
+ )
77
+
78
+ # We want to properly sort by rotation name, which is a bit intricate, as it's a string of two numbers divided by a
79
+ # '/' character. We can't just sort by the string, as "10/11" would come after "10/1". We need to split the string
80
+ # into its components and sort by them.
81
+ df = pd.DataFrame(result)
82
+ df["line_name"] = df["rotation_name"].apply(rotation_name_for_sorting)
83
+
84
+ df.sort_values(by=["line_name", "time_start"], inplace=True)
85
+
86
+ return df
@@ -0,0 +1,39 @@
1
+ import pandas as pd
2
+ import plotly.express as px # type: ignore
3
+ import plotly.graph_objs as go # type: ignore
4
+
5
+
6
+ def rotation_info(prepared_data: pd.DataFrame) -> go.Figure:
7
+ """
8
+ This function visualizes the rotation information using plotly
9
+
10
+ :param prepared_data: The result of the rotation_info function, a dataframe with the following columns:
11
+
12
+ - rotation_id: the id of the rotation
13
+ - rotation_name: the name of the rotation
14
+ - vehicle_type_id: the id of the vehicle type
15
+ - vehicle_type_name: the name of the vehicle type
16
+ - total_distance: the total distance of the rotation
17
+ - time_start: the departure of the first trip
18
+ - time_end: the arrival of the last trip
19
+ - line_name: the name of the line, which is the first part of the rotation name. Used for sorting
20
+
21
+ :return: A plotly figure object
22
+ """
23
+
24
+ fig = px.timeline(
25
+ prepared_data,
26
+ x_start="time_start",
27
+ x_end="time_end",
28
+ y="rotation_name",
29
+ color="total_distance",
30
+ labels={
31
+ "rotation_name": "Rotation Name",
32
+ "total_distance": "Total Distance (km)",
33
+ "line_name": "Line Name",
34
+ },
35
+ hover_data=["vehicle_type_name"],
36
+ pattern_shape="line_name",
37
+ )
38
+ fig.update_layout(legend_orientation="h")
39
+ return fig
File without changes
@@ -0,0 +1,388 @@
1
+ from datetime import datetime, timedelta
2
+ from typing import Dict, List, Iterable, Tuple
3
+
4
+ import numpy as np
5
+ import numpy.typing as npt
6
+ import pandas as pd
7
+ import sqlalchemy
8
+ from eflips.model import Event, Rotation, Vehicle, Trip, EventType
9
+ from sqlalchemy.orm import Session
10
+
11
+
12
+ def departure_arrival_soc(
13
+ scenario_id: int, session: sqlalchemy.orm.session.Session
14
+ ) -> pd.DataFrame:
15
+ """
16
+ This function creates a dataframe with the SoC at departure and arrival for each trip.
17
+ The columns are
18
+ - event_id: the associated event id
19
+ - rotation_id: the associated rotation id (for the trip the vehicle is going on or returning from)
20
+ - rotation_name: the name of the rotation
21
+ - vehicle_type_id: the vehicle type id
22
+ - vehicle_type_name: the name of the vehicle type
23
+ - vehicle_id: the vehicle id
24
+ - vehicle_name: the name of the vehicle
25
+ - time: the time at which this SoC was recorded (for departure, this is the departure time from the depot, for
26
+ arrival, this is the arrival time at the depot)
27
+ - soc: the state of charge at the given time
28
+ - event_type: the type of event, either "Departure" or "Arrival"
29
+
30
+ :param scenario_id: The scenario id for which to create the dataframe
31
+ :param session: An sqlalchemy session to an eflips-model database
32
+
33
+ :return: A pandas DataFrame
34
+ """
35
+
36
+ vehicles = session.query(Vehicle).filter(Vehicle.scenario_id == scenario_id)
37
+ result: List[Dict[str, str | int | float | datetime]] = []
38
+
39
+ for vehicle in vehicles:
40
+ events = (
41
+ session.query(Event)
42
+ .filter(Event.vehicle_id == vehicle.id)
43
+ .order_by(Event.time_start)
44
+ .all()
45
+ )
46
+ for i in range(len(events)):
47
+ # The interesting events for us are the ones immediately before or after an event at a depot.
48
+ # That means the previous or next event has an area_id, the event does not
49
+
50
+ if events[i].area_id is None:
51
+ if i > 0 and events[i - 1].area_id is not None:
52
+ # This is a departure event
53
+ result.append(
54
+ {
55
+ "rotation_id": events[i].trip.rotation_id,
56
+ "rotation_name": events[i].trip.rotation.name,
57
+ "vehicle_type_id": vehicle.vehicle_type_id,
58
+ "vehicle_type_name": vehicle.vehicle_type.name,
59
+ "vehicle_id": vehicle.id,
60
+ "vehicle_name": vehicle.name,
61
+ "time": events[i].time_start,
62
+ "soc": events[i].soc_start * 100,
63
+ "event_type": "Departure",
64
+ }
65
+ )
66
+ elif i < len(events) - 1 and events[i + 1].area_id is not None:
67
+ # This is an arrival event
68
+ result.append(
69
+ {
70
+ "rotation_id": events[i].trip.rotation_id,
71
+ "rotation_name": events[i].trip.rotation.name,
72
+ "vehicle_type_id": vehicle.vehicle_type_id,
73
+ "vehicle_type_name": vehicle.vehicle_type.name,
74
+ "vehicle_id": vehicle.id,
75
+ "vehicle_name": vehicle.name,
76
+ "time": events[i].time_end,
77
+ "soc": events[i].soc_end * 100,
78
+ "event_type": "Arrival",
79
+ }
80
+ )
81
+
82
+ return pd.DataFrame(result)
83
+
84
+
85
+ def depot_event(
86
+ scenario_id: int, session: Session, vehicle_ids: None | int | List[int] = None
87
+ ) -> pd.DataFrame:
88
+ """
89
+ This function creates a dataframe with all the events at the depot for a given scenario.
90
+ The columns are
91
+ - time_start: the start time of the event in datetime format
92
+ - time_end: the end time of the event in datetime format
93
+ - vehicle_id: the unique vehicle identifier which could be used for querying the vehicle in the database
94
+ - event_type: the type of event specified in the eflips model. See :class:`eflips.model.EventType` for more information
95
+ - area_id: the unique area identifier which could be used for querying the area in the database
96
+ - trip_id: the unique trip identifier which could be used for querying the trip in the database
97
+ - station_id: the unique station identifier which could be used for querying the station in the database
98
+ - location: the location of the event. This could be "depot", "trip" or "station"
99
+
100
+
101
+ :param scenario_id: The unique identifier of the scenario
102
+ :param session: A :class:`sqlalchemy.orm.session.Session` object to an eflips-model database
103
+ :param vehicle_ids: A list of vehicle ids to filter for. If None, all vehicles are included
104
+ :return: A pandas DataFrame
105
+ """
106
+
107
+ event_list_for_plot: List[Dict[str, int | float | str | datetime | None]] = []
108
+ events_from_db_q = (
109
+ session.query(Event)
110
+ .filter(Event.scenario_id == scenario_id)
111
+ .order_by(Event.vehicle_id, Event.time_start)
112
+ )
113
+
114
+ if vehicle_ids is not None:
115
+ if isinstance(vehicle_ids, int):
116
+ vehicle_ids = [vehicle_ids]
117
+ events_from_db_q = events_from_db_q.filter(Event.vehicle_id.in_(vehicle_ids))
118
+
119
+ events_from_db = events_from_db_q.all()
120
+
121
+ for event in events_from_db:
122
+ location = None
123
+ if event.area_id is not None:
124
+ location = "Depot"
125
+ elif event.trip_id is not None:
126
+ location = "Trip"
127
+ elif event.station_id is not None:
128
+ location = "Station"
129
+
130
+ event_list_for_plot.append(
131
+ {
132
+ "time_start": event.time_start,
133
+ "time_end": event.time_end,
134
+ "soc_start": event.soc_start,
135
+ "soc_end": event.soc_end,
136
+ "vehicle_id": str(event.vehicle_id),
137
+ "vehicle_type_id": event.vehicle.vehicle_type_id,
138
+ "vehicle_type_name": event.vehicle.vehicle_type.name,
139
+ "event_type": event.event_type.name.replace("_", " ").title(),
140
+ "area_id": event.area_id,
141
+ "trip_id": event.trip_id,
142
+ "station_id": event.station_id,
143
+ "location": location,
144
+ }
145
+ )
146
+
147
+ df = pd.DataFrame(event_list_for_plot)
148
+ df.sort_values(by=["vehicle_type_name", "time_start"], inplace=True)
149
+
150
+ return df
151
+
152
+
153
+ def power_and_occupancy(
154
+ aread_id: int | Iterable[int],
155
+ session: sqlalchemy.orm.session.Session,
156
+ temporal_resolution: int = 60,
157
+ ) -> pd.DataFrame:
158
+ """
159
+ This function creates a dataframe containing a timeseries of the power and occupancy of the given area(s).
160
+ The columns are:
161
+ - time: the time at which the data was recorded
162
+ - power: the summed power consumption of the area(s) at the given time
163
+ - occupancy: the summed occupancy of the area(s) at the given time
164
+
165
+ :param aread_id: The id of the area for which to create the dataframe
166
+ :param session: An sqlalchemy session to an eflips-model database
167
+ :param temporal_resolution: The temporal resolution of the timeseries in seconds. Default is 60 seconds.
168
+ :return: A pandas DataFrame
169
+ """
170
+ if isinstance(aread_id, int):
171
+ aread_id = [aread_id]
172
+ events = session.query(Event).filter(Event.area_id.in_(aread_id))
173
+
174
+ start_time_row = (
175
+ session.query(Event.time_start)
176
+ .filter(Event.area_id.in_(aread_id))
177
+ .order_by(Event.time_start)
178
+ .first()
179
+ )
180
+ if start_time_row is None:
181
+ raise ValueError("No events found for the given area_id")
182
+ start_time = start_time_row[0] # Oh, if we had nullability operators in Python…
183
+ end_time_row = (
184
+ session.query(Event.time_end)
185
+ .filter(Event.area_id.in_(aread_id))
186
+ .order_by(Event.time_end.desc())
187
+ .first()
188
+ )
189
+ if end_time_row is None:
190
+ raise ValueError("No events found for the given area_id")
191
+ end_time = end_time_row[0] # Oh, if we had nullability operators in Python…
192
+
193
+ # Round the start and end times to the nearest temporal_resolution
194
+ start_time = start_time - timedelta(seconds=start_time.second % temporal_resolution)
195
+ end_time = end_time + timedelta(
196
+ seconds=temporal_resolution - end_time.second % temporal_resolution
197
+ )
198
+
199
+ # Create a 1-second interval time series
200
+ time: npt.NDArray[np.datetime64] = np.arange(
201
+ start_time, end_time, timedelta(seconds=temporal_resolution)
202
+ )
203
+ time_as_unix = np.arange(
204
+ start_time.timestamp(), end_time.timestamp(), temporal_resolution
205
+ )
206
+ energy = np.zeros(time.shape[0])
207
+ occupancy = np.zeros(time.shape[0])
208
+
209
+ # For each event:
210
+ # Convert SoC to energy
211
+ # Resample to 1-second intervals
212
+ # Add the energy to the energy series
213
+ for event in events:
214
+ if event.timeseries is not None:
215
+ this_event_times: List[datetime] = [datetime.fromisoformat(t) for t in event.timeseries["time"]] # type: ignore
216
+ this_event_socs: List[float] = event.timeseries["soc"] # type: ignore
217
+ else:
218
+ this_event_times = []
219
+ this_event_socs = []
220
+ # Attach the event's time_start and soc to the timeseries at the beginning
221
+ this_event_times.insert(0, event.time_start)
222
+ this_event_socs.insert(0, event.soc_start)
223
+ # Attach the event's time_end and soc to the timeseries at the end
224
+ this_event_times.append(event.time_end)
225
+ this_event_socs.append(event.soc_end)
226
+ this_event_unix_times = np.array([t.timestamp() for t in this_event_times])
227
+
228
+ # Validation: the timeseries should be sorted and the socs should be in the range [0, 1] and monotonically increasing
229
+ assert all(
230
+ this_event_times[i] <= this_event_times[i + 1]
231
+ for i in range(len(this_event_times) - 1)
232
+ )
233
+ assert all(0 <= this_event_socs[i] <= 1 for i in range(len(this_event_socs)))
234
+ assert all(
235
+ this_event_socs[i] <= this_event_socs[i + 1]
236
+ for i in range(len(this_event_socs) - 1)
237
+ )
238
+
239
+ # Convert from SoC to enerhgy using the vehicle types battery capacity
240
+ this_event_energy = (
241
+ np.array(this_event_socs) * event.vehicle.vehicle_type.battery_capacity
242
+ ) # kWh
243
+
244
+ # Resample the energy to 1-second intervals
245
+ this_event_energy = np.interp(
246
+ time_as_unix, this_event_unix_times, this_event_energy
247
+ )
248
+ energy += this_event_energy
249
+
250
+ # For occupancy, we we create an entry at the beginning and end of the event, then resample to 1-second intervals
251
+ # with left=0 and right=0
252
+ this_event_occupancy = np.interp(
253
+ time_as_unix,
254
+ [event.time_start.timestamp(), event.time_end.timestamp()],
255
+ [1, 1],
256
+ left=0,
257
+ right=0,
258
+ )
259
+ occupancy += this_event_occupancy
260
+
261
+ # Calculate the power from the energy
262
+ power = (np.diff(energy) / np.diff(time_as_unix).astype(float)) * 3600 # kW
263
+
264
+ # Create the dataframe
265
+ result = pd.DataFrame(
266
+ {
267
+ "time": time[1:],
268
+ "power": power,
269
+ "occupancy": occupancy[1:],
270
+ }
271
+ )
272
+ return result
273
+
274
+
275
+ def specific_energy_consumption(scenario_id: int, session: Session) -> pd.DataFrame:
276
+ """
277
+ Creates a dataframe of all the trip energy consumptions and distances for the given scenario.
278
+ The dataframe contains the following columns:
279
+ - trip_id: the unique identifier of the trip
280
+ - route_id: the unique identifier of the route
281
+ - route_name: the name of the route
282
+ - distance: the distance of the route in km
283
+ - energy_consumption: the energy consumption of the trip in kWh
284
+ - vehicle_type_id: the unique identifier of the vehicle type
285
+ - vehicle_type_name: the name of the vehicle type
286
+
287
+ :param scenario_id: The unique identifier of the scenario
288
+ :param session: An sqlalchemy session to an eflips-model database
289
+ :return: A pandas DataFrame
290
+ """
291
+ events = (
292
+ session.query(Event)
293
+ .filter(Event.scenario_id == scenario_id)
294
+ .filter(Event.event_type == EventType.DRIVING)
295
+ .options(sqlalchemy.orm.joinedload(Event.trip).joinedload(Trip.route))
296
+ )
297
+ result: List[Dict[str, int | float | str]] = []
298
+ for event in events:
299
+ trip = event.trip
300
+ delta_soc = event.soc_end - event.soc_start
301
+ delta_energy = delta_soc * trip.rotation.vehicle_type.battery_capacity * -1
302
+ result.append(
303
+ {
304
+ "trip_id": trip.id,
305
+ "route_id": trip.route_id,
306
+ "route_name": trip.route.name,
307
+ "distance": trip.route.distance / 1000,
308
+ "energy_consumption": delta_energy,
309
+ "vehicle_type_id": trip.rotation.vehicle_type_id,
310
+ "vehicle_type_name": trip.rotation.vehicle_type.name,
311
+ }
312
+ )
313
+ return pd.DataFrame(result)
314
+
315
+
316
+ def vehicle_soc(
317
+ vehicle_id: int, session: Session
318
+ ) -> Tuple[pd.DataFrame, Dict[str, List[Tuple[str, datetime, datetime]]]]:
319
+ """
320
+ This function takes in a vehicle id and returns a description what happened to the vehicle over time.
321
+ The dataframe contains the following columns:
322
+ - time: the time at which the SoC was recorded
323
+ - soc: the state of charge at the given time
324
+
325
+ Additionally, a dictionary for the different kinds of events is returned. For each kind of event, a list of Tuples
326
+ with a description of the event, the start time and the end time is returned.
327
+
328
+ The kinds of events are:
329
+ - "rotation": A list of rotation names and the time the rotation started and ended
330
+ - "charging": A list of the location of the charging and the time the charging started and ended
331
+
332
+ :param vehicle_id: the unique identifier of the vehicle
333
+ :param session: A :class:`sqlalchemy.orm.session.Session` object to an eflips-model database
334
+ :return: A pandas DataFrame
335
+ """
336
+ events_from_db = (
337
+ session.query(Event)
338
+ .filter(Event.vehicle_id == vehicle_id)
339
+ .order_by(Event.time_start)
340
+ .all()
341
+ )
342
+
343
+ descriptions: Dict[str, List[Tuple[str, datetime, datetime]]] = {
344
+ "rotation": [],
345
+ "charging": [],
346
+ }
347
+
348
+ # Go through all events and connect the soc_start and soc_end and time_start and time_end
349
+ all_times = []
350
+ all_soc = []
351
+ for event in events_from_db:
352
+ all_times.append(event.time_start)
353
+ all_times.append(event.time_end)
354
+
355
+ if event.timeseries is not None:
356
+ this_event_times: List[datetime] = [
357
+ datetime.fromisoformat(t) for t in event.timeseries["time"] # type: ignore
358
+ ]
359
+ this_event_socs: List[float] = event.timeseries["soc"] # type: ignore
360
+ all_times.extend(this_event_times)
361
+ all_soc.extend(this_event_socs)
362
+
363
+ all_soc.append(event.soc_start)
364
+ all_soc.append(event.soc_end)
365
+
366
+ if (
367
+ event.event_type == EventType.CHARGING_DEPOT
368
+ or event.event_type == EventType.CHARGING_OPPORTUNITY
369
+ ):
370
+ if event.area is not None:
371
+ name = event.area.name + " in " + event.area.depot.name
372
+ else:
373
+ name = event.station.name
374
+
375
+ descriptions["charging"].append((name, event.time_start, event.time_end))
376
+
377
+ for rotation in (
378
+ session.query(Rotation).filter(Rotation.vehicle_id == vehicle_id).all()
379
+ ):
380
+ descriptions["rotation"].append(
381
+ (
382
+ rotation.name,
383
+ rotation.trips[0].departure_time,
384
+ rotation.trips[-1].arrival_time,
385
+ )
386
+ )
387
+
388
+ return (pd.DataFrame({"time": all_times, "soc": all_soc}), descriptions)