mobev 0.1.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.
- mobev/__init__.py +0 -0
- mobev/charging/__init__.py +1 -0
- mobev/charging/charging_availability_assigner.py +187 -0
- mobev/charging/ev_fleet_sampler.py +140 -0
- mobev/charging/parking_interval_builder.py +207 -0
- mobev/charging/parking_location_normalizer.py +83 -0
- mobev/charging/trip_schedule_validator.py +53 -0
- mobev/core/charging_resolver.py +149 -0
- mobev/core/config.py +579 -0
- mobev/core/pipeline.py +129 -0
- mobev/core/profile_simulator.py +150 -0
- mobev/core/result.py +144 -0
- mobev/core/trip_preparer.py +142 -0
- mobev/load_profiles/__init__.py +1 -0
- mobev/load_profiles/battery_state_initializer.py +46 -0
- mobev/load_profiles/charging_session_builder.py +775 -0
- mobev/load_profiles/charging_start_planner.py +78 -0
- mobev/load_profiles/load_profile_aggregator.py +138 -0
- mobev/load_profiles/tariff_manager.py +442 -0
- mobev/trips/__init__.py +1 -0
- mobev/trips/trip_feature_builder.py +180 -0
- mobev/trips/trip_location_indexer.py +116 -0
- mobev/trips/weekly_trip_loader.py +134 -0
- mobev/utils/logger.py +96 -0
- mobev/utils/plot_profile.py +1132 -0
- mobev/utils/plot_trips.py +365 -0
- mobev/utils/results_indicators.py +132 -0
- mobev-0.1.0.dist-info/METADATA +196 -0
- mobev-0.1.0.dist-info/RECORD +32 -0
- mobev-0.1.0.dist-info/WHEEL +5 -0
- mobev-0.1.0.dist-info/licenses/LICENSE +25 -0
- mobev-0.1.0.dist-info/top_level.txt +1 -0
mobev/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Provide support code for the MobEV pipeline."""
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Assign persistent charging stations and enrich parking opportunities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
from mobev.core.config import Config
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChargingAvailabilityAssigner:
|
|
16
|
+
"""Assign persistent chargers to location and location-type pairs.
|
|
17
|
+
|
|
18
|
+
Inputs: the global configuration, deduplicated location-type pairs,
|
|
19
|
+
normalized parking events, and a random number generator.
|
|
20
|
+
|
|
21
|
+
Process: samples one charger assignment for every ``(location_id,
|
|
22
|
+
location_type)`` pair, then joins that persistent assignment to each
|
|
23
|
+
matching parking event and applies the minimum parking-duration rule.
|
|
24
|
+
|
|
25
|
+
Outputs: a charging-station table and a parking-event table enriched with
|
|
26
|
+
charger availability, chargeability flags, and charging power.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: Config):
|
|
30
|
+
"""Store the simulation configuration."""
|
|
31
|
+
self.config = config
|
|
32
|
+
|
|
33
|
+
def assign(
|
|
34
|
+
self,
|
|
35
|
+
location_types_df: pl.DataFrame,
|
|
36
|
+
parking_df: pl.DataFrame,
|
|
37
|
+
rng: np.random.Generator,
|
|
38
|
+
) -> tuple[pl.DataFrame, pl.DataFrame]:
|
|
39
|
+
"""Return persistent station assignments and enriched parking events.
|
|
40
|
+
|
|
41
|
+
RNG contract: availability is drawn once for every sorted unique
|
|
42
|
+
``(location_id, location_type)`` pair. Power is then drawn by sorted
|
|
43
|
+
location type for available stations only. A fixed seed and identical
|
|
44
|
+
location-type inventory therefore produce identical assignments.
|
|
45
|
+
"""
|
|
46
|
+
charging_station_df = self._sample_stations(location_types_df, rng)
|
|
47
|
+
minimum_parking_s = self.config.minimum_parking_time_minutes * 60.0
|
|
48
|
+
|
|
49
|
+
if parking_df.is_empty():
|
|
50
|
+
logger.debug(
|
|
51
|
+
"Assigned %s charging stations. No parking events.",
|
|
52
|
+
charging_station_df.height,
|
|
53
|
+
)
|
|
54
|
+
return charging_station_df, parking_df.with_columns(
|
|
55
|
+
[
|
|
56
|
+
pl.lit(None, dtype=pl.Boolean).alias("charger_available"),
|
|
57
|
+
pl.lit(None, dtype=pl.Float64).alias("charging_power_kw"),
|
|
58
|
+
pl.lit(None, dtype=pl.Boolean).alias("is_chargeable"),
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
opportunities_df = parking_df.join(
|
|
63
|
+
charging_station_df,
|
|
64
|
+
on=["location_id", "location_type"],
|
|
65
|
+
how="left",
|
|
66
|
+
).with_columns(
|
|
67
|
+
(
|
|
68
|
+
(pl.col("parking_duration_s") >= minimum_parking_s)
|
|
69
|
+
& pl.col("charger_available")
|
|
70
|
+
).alias("is_chargeable")
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
logger.debug(
|
|
74
|
+
"Assigned %s charging stations to %s unique locations. Charging power distribution: %s.",
|
|
75
|
+
int(charging_station_df.get_column("charger_available").sum() or 0),
|
|
76
|
+
charging_station_df.height,
|
|
77
|
+
charging_station_df.group_by("charging_power_kw")
|
|
78
|
+
.len()
|
|
79
|
+
.rename({"len": "count"})
|
|
80
|
+
.sort("charging_power_kw"),
|
|
81
|
+
)
|
|
82
|
+
logger.debug(
|
|
83
|
+
"Across %s parking event(s), %s are chargeable.",
|
|
84
|
+
opportunities_df.height,
|
|
85
|
+
int(opportunities_df.get_column("is_chargeable").sum() or 0),
|
|
86
|
+
)
|
|
87
|
+
return charging_station_df, opportunities_df
|
|
88
|
+
|
|
89
|
+
def _sample_stations(
|
|
90
|
+
self,
|
|
91
|
+
location_types_df: pl.DataFrame,
|
|
92
|
+
rng: np.random.Generator,
|
|
93
|
+
) -> pl.DataFrame:
|
|
94
|
+
"""Sample one persistent charger for every location and type pair."""
|
|
95
|
+
required_columns = {"location_id", "location_type"}
|
|
96
|
+
missing_columns = required_columns.difference(location_types_df.columns)
|
|
97
|
+
if missing_columns:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Location-type inventory is missing columns: {sorted(missing_columns)}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
stations_df = (
|
|
103
|
+
location_types_df.select(["location_id", "location_type"])
|
|
104
|
+
.with_columns(
|
|
105
|
+
[
|
|
106
|
+
pl.col("location_id").cast(pl.String),
|
|
107
|
+
pl.col("location_type").cast(pl.String),
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
.unique()
|
|
111
|
+
.sort(["location_id", "location_type"])
|
|
112
|
+
)
|
|
113
|
+
if stations_df.is_empty():
|
|
114
|
+
return pl.DataFrame(
|
|
115
|
+
schema={
|
|
116
|
+
"location_id": pl.String,
|
|
117
|
+
"location_type": pl.String,
|
|
118
|
+
"charger_available": pl.Boolean,
|
|
119
|
+
"charging_power_kw": pl.Float64,
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
station_rows = stations_df.to_dicts()
|
|
124
|
+
availability_draws = rng.random(len(station_rows))
|
|
125
|
+
charger_available = np.zeros(len(station_rows), dtype=bool)
|
|
126
|
+
charging_power_kw = np.zeros(len(station_rows), dtype=float)
|
|
127
|
+
location_types = sorted({str(row["location_type"]) for row in station_rows})
|
|
128
|
+
|
|
129
|
+
for index, station in enumerate(station_rows):
|
|
130
|
+
probability = self.config.get_location_config(
|
|
131
|
+
str(station["location_type"])
|
|
132
|
+
)["charger_availability_probability"]
|
|
133
|
+
charger_available[index] = availability_draws[index] <= probability
|
|
134
|
+
|
|
135
|
+
for location_type in location_types:
|
|
136
|
+
available_indices = np.array(
|
|
137
|
+
[
|
|
138
|
+
index
|
|
139
|
+
for index, station in enumerate(station_rows)
|
|
140
|
+
if station["location_type"] == location_type
|
|
141
|
+
and charger_available[index]
|
|
142
|
+
],
|
|
143
|
+
dtype=int,
|
|
144
|
+
)
|
|
145
|
+
if available_indices.size == 0:
|
|
146
|
+
continue
|
|
147
|
+
distribution = self.config.get_location_config(location_type)[
|
|
148
|
+
"charging_power_distribution"
|
|
149
|
+
]
|
|
150
|
+
charging_power_kw[available_indices] = self._sample_power_distribution(
|
|
151
|
+
distribution,
|
|
152
|
+
available_indices.size,
|
|
153
|
+
rng,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return stations_df.with_columns(
|
|
157
|
+
[
|
|
158
|
+
pl.Series("charger_available", charger_available),
|
|
159
|
+
pl.Series("charging_power_kw", charging_power_kw),
|
|
160
|
+
]
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _sample_power_distribution(
|
|
165
|
+
distribution: dict[str, object],
|
|
166
|
+
size: int,
|
|
167
|
+
rng: np.random.Generator,
|
|
168
|
+
) -> np.ndarray:
|
|
169
|
+
"""Draw ``size`` charging powers for one configured location type."""
|
|
170
|
+
distribution_type = distribution["type"]
|
|
171
|
+
|
|
172
|
+
if distribution_type == "fixed":
|
|
173
|
+
return np.full(size, float(distribution["value"]), dtype=float)
|
|
174
|
+
|
|
175
|
+
if distribution_type == "categorical":
|
|
176
|
+
values = np.asarray(distribution["values"], dtype=float)
|
|
177
|
+
weights = np.asarray(distribution["weights"], dtype=float)
|
|
178
|
+
return rng.choice(values, size=size, p=weights / weights.sum())
|
|
179
|
+
|
|
180
|
+
if distribution_type == "uniform":
|
|
181
|
+
return rng.uniform(
|
|
182
|
+
float(distribution["low"]),
|
|
183
|
+
float(distribution["high"]),
|
|
184
|
+
size=size,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
raise ValueError(f"Unsupported distribution type: {distribution_type!r}")
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Select EV-owning people and sample their vehicle and tariff attributes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
from mobev.core.config import Config
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EVFleetSampler:
|
|
16
|
+
"""Inputs: the global configuration, a prepared trip table, and a random
|
|
17
|
+
number generator.
|
|
18
|
+
|
|
19
|
+
Process: samples which people own EVs, then assigns EV attributes using
|
|
20
|
+
either fixed values or configured fleet segments, and filters the
|
|
21
|
+
trip table to the sampled EV population.
|
|
22
|
+
|
|
23
|
+
Outputs: a vehicle table describing the sampled EV fleet and the subset of
|
|
24
|
+
trip rows belonging to those EVs.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: Config):
|
|
28
|
+
"""Implement the internal init operation."""
|
|
29
|
+
self.config = config
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _sample_initial_soc(
|
|
33
|
+
method: str,
|
|
34
|
+
fixed_soc: float,
|
|
35
|
+
distribution: dict[str, float],
|
|
36
|
+
count: int,
|
|
37
|
+
rng: np.random.Generator,
|
|
38
|
+
) -> np.ndarray:
|
|
39
|
+
"""Draw fixed or bounded normal initial SOC values."""
|
|
40
|
+
if method == "fixed":
|
|
41
|
+
values = np.full(count, fixed_soc, dtype=float)
|
|
42
|
+
elif method == "distribution":
|
|
43
|
+
values = rng.normal(distribution["mean"], distribution["std"], size=count)
|
|
44
|
+
values = np.clip(values, distribution["min"], distribution["max"])
|
|
45
|
+
else:
|
|
46
|
+
raise ValueError(f"Unsupported initial SOC method: {method!r}")
|
|
47
|
+
return np.clip(values, 0.0, 1.0)
|
|
48
|
+
|
|
49
|
+
def sample(
|
|
50
|
+
self,
|
|
51
|
+
trips_df: pl.DataFrame,
|
|
52
|
+
rng: np.random.Generator,
|
|
53
|
+
) -> tuple[pl.DataFrame, pl.DataFrame]:
|
|
54
|
+
"""Return sampled EV vehicles and the subset of trips belonging to them."""
|
|
55
|
+
person_count = (
|
|
56
|
+
trips_df["person_id"].n_unique() if not trips_df.is_empty() else 0
|
|
57
|
+
)
|
|
58
|
+
logger.debug("Sampling EV ownership for %s unique people.", person_count)
|
|
59
|
+
person_ids = (
|
|
60
|
+
trips_df.select("person_id")
|
|
61
|
+
.unique()
|
|
62
|
+
.sort("person_id")
|
|
63
|
+
.to_series()
|
|
64
|
+
.to_numpy()
|
|
65
|
+
)
|
|
66
|
+
ev_person_ids = person_ids[
|
|
67
|
+
rng.random(person_ids.size) <= self.config.ev_penetration
|
|
68
|
+
]
|
|
69
|
+
ev_count = ev_person_ids.size
|
|
70
|
+
|
|
71
|
+
if self.config.attribute_mode == "segment":
|
|
72
|
+
if self.config.fleet_segments is None:
|
|
73
|
+
raise ValueError("Segment sampling requires config.fleet_segments.")
|
|
74
|
+
segments = self.config.fleet_segments
|
|
75
|
+
shares = np.asarray([segment["share"] for segment in segments], dtype=float)
|
|
76
|
+
segment_indices = rng.choice(
|
|
77
|
+
len(segments), size=ev_count, p=shares / shares.sum()
|
|
78
|
+
)
|
|
79
|
+
segment_names = np.asarray(
|
|
80
|
+
[segment["name"] for segment in segments], dtype=str
|
|
81
|
+
)[segment_indices]
|
|
82
|
+
capacities_kwh = np.asarray(
|
|
83
|
+
[segment["battery_capacity_kwh"] for segment in segments], dtype=float
|
|
84
|
+
)[segment_indices]
|
|
85
|
+
consumptions_kwh_per_km = np.asarray(
|
|
86
|
+
[segment["consumption_kwh_per_km"] for segment in segments], dtype=float
|
|
87
|
+
)[segment_indices]
|
|
88
|
+
else:
|
|
89
|
+
segment_names = np.full(ev_count, "fixed")
|
|
90
|
+
capacities_kwh = np.full(
|
|
91
|
+
ev_count, self.config.fixed_vehicle_battery_capacity_kwh, dtype=float
|
|
92
|
+
)
|
|
93
|
+
consumptions_kwh_per_km = np.full(
|
|
94
|
+
ev_count, self.config.fixed_vehicle_consumption_kwh_per_km, dtype=float
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
initial_soc_fractions = self._sample_initial_soc(
|
|
98
|
+
self.config.initial_soc_mode,
|
|
99
|
+
self.config.fixed_initial_soc_fraction,
|
|
100
|
+
self.config.initial_soc_distribution,
|
|
101
|
+
ev_count,
|
|
102
|
+
rng,
|
|
103
|
+
)
|
|
104
|
+
follows_tariff = (
|
|
105
|
+
rng.random(ev_count) <= self.config.tariff_following_probability
|
|
106
|
+
if self.config.tariff_enabled
|
|
107
|
+
else np.zeros(ev_count, dtype=bool)
|
|
108
|
+
)
|
|
109
|
+
vehicles_df = pl.DataFrame(
|
|
110
|
+
{
|
|
111
|
+
"vehicle_id": np.asarray(
|
|
112
|
+
[f"ev-{person_id}" for person_id in ev_person_ids]
|
|
113
|
+
),
|
|
114
|
+
"person_id": ev_person_ids,
|
|
115
|
+
"vehicle_segment": segment_names,
|
|
116
|
+
"battery_capacity_kwh": capacities_kwh,
|
|
117
|
+
"consumption_kwh_per_km": consumptions_kwh_per_km,
|
|
118
|
+
"initial_soc_fraction": initial_soc_fractions,
|
|
119
|
+
"min_soc_fraction": np.full(ev_count, self.config.minimum_soc_fraction),
|
|
120
|
+
"max_soc_fraction": np.full(ev_count, self.config.maximum_soc_fraction),
|
|
121
|
+
"min_energy_kwh": capacities_kwh * self.config.minimum_soc_fraction,
|
|
122
|
+
"max_energy_kwh": capacities_kwh * self.config.maximum_soc_fraction,
|
|
123
|
+
"follows_tariff": follows_tariff,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
if vehicles_df.is_empty():
|
|
127
|
+
logger.warning("No EVs were sampled from the provided trips.")
|
|
128
|
+
return vehicles_df, trips_df.head(0)
|
|
129
|
+
|
|
130
|
+
filtered_trips = trips_df.join(
|
|
131
|
+
vehicles_df.select(["person_id", "vehicle_id"]),
|
|
132
|
+
on="person_id",
|
|
133
|
+
how="inner",
|
|
134
|
+
)
|
|
135
|
+
logger.debug(
|
|
136
|
+
"Sampled %s EVs and retained %s EV trip rows.",
|
|
137
|
+
vehicles_df.height,
|
|
138
|
+
filtered_trips.height,
|
|
139
|
+
)
|
|
140
|
+
return vehicles_df, filtered_trips
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Construct parking intervals between chronological vehicle trips."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
from mobev.core.config import Config
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ParkingIntervalBuilder:
|
|
16
|
+
"""Build parking intervals from chronologically ordered EV trips."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, config: Config):
|
|
19
|
+
"""Implement the internal init operation."""
|
|
20
|
+
self.config = config
|
|
21
|
+
self.simulation_end_datetime = config.start_datetime + timedelta(days=7)
|
|
22
|
+
|
|
23
|
+
def build(self, trips_df: pl.DataFrame) -> pl.DataFrame:
|
|
24
|
+
"""Return parking intervals derived from the validated trip schedule."""
|
|
25
|
+
if trips_df.is_empty():
|
|
26
|
+
logger.warning(
|
|
27
|
+
"Received empty trips dataframe when building parking intervals."
|
|
28
|
+
)
|
|
29
|
+
return self._empty_parking_events()
|
|
30
|
+
|
|
31
|
+
logger.debug("Building parking intervals from %s trip rows.", trips_df.height)
|
|
32
|
+
parking_df = self._build_lazy_intervals(trips_df).collect(engine="streaming")
|
|
33
|
+
logger.debug("Built %s parking intervals.", parking_df.height)
|
|
34
|
+
return parking_df
|
|
35
|
+
|
|
36
|
+
def _build_lazy_intervals(self, trips_df: pl.DataFrame) -> pl.LazyFrame:
|
|
37
|
+
"""Build initial, intermediate, and final parking events column-wise."""
|
|
38
|
+
sort_columns = ["person_id", "departure_datetime"]
|
|
39
|
+
if "trip_index" in trips_df.columns:
|
|
40
|
+
sort_columns.append("trip_index")
|
|
41
|
+
|
|
42
|
+
simulation_start = pl.lit(self.config.start_datetime, dtype=pl.Datetime)
|
|
43
|
+
simulation_end = pl.lit(self.simulation_end_datetime, dtype=pl.Datetime)
|
|
44
|
+
ordered_trips = (
|
|
45
|
+
trips_df.lazy()
|
|
46
|
+
.sort(sort_columns)
|
|
47
|
+
.with_columns(
|
|
48
|
+
[
|
|
49
|
+
pl.col("trip_id")
|
|
50
|
+
.cum_count()
|
|
51
|
+
.over("person_id")
|
|
52
|
+
.alias("trip_position"),
|
|
53
|
+
pl.col("trip_id").shift(-1).over("person_id").alias("next_trip_id"),
|
|
54
|
+
pl.col("departure_datetime")
|
|
55
|
+
.shift(-1)
|
|
56
|
+
.over("person_id")
|
|
57
|
+
.alias("next_departure_datetime"),
|
|
58
|
+
pl.col("preceding_purpose")
|
|
59
|
+
.shift(-1)
|
|
60
|
+
.over("person_id")
|
|
61
|
+
.alias("next_preceding_purpose"),
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
overlap_count = (
|
|
67
|
+
ordered_trips.filter(
|
|
68
|
+
pl.col("next_departure_datetime").is_not_null()
|
|
69
|
+
& (pl.col("arrival_datetime") > pl.col("next_departure_datetime"))
|
|
70
|
+
)
|
|
71
|
+
.select(pl.len())
|
|
72
|
+
.collect()
|
|
73
|
+
.item()
|
|
74
|
+
)
|
|
75
|
+
if overlap_count:
|
|
76
|
+
logger.error("Detected %s negative parking intervals.", overlap_count)
|
|
77
|
+
raise ValueError("Parking event duration cannot be negative.")
|
|
78
|
+
|
|
79
|
+
initial_events = ordered_trips.filter(
|
|
80
|
+
(pl.col("trip_position") == 1)
|
|
81
|
+
& (pl.col("departure_datetime") > simulation_start)
|
|
82
|
+
).select(
|
|
83
|
+
self._event_columns(
|
|
84
|
+
vehicle_id=pl.col("vehicle_id"),
|
|
85
|
+
person_id=pl.col("person_id"),
|
|
86
|
+
start_datetime=simulation_start,
|
|
87
|
+
end_datetime=pl.col("departure_datetime"),
|
|
88
|
+
location_id=pl.col("start_location_id"),
|
|
89
|
+
location_type=self._first_nonempty("preceding_purpose"),
|
|
90
|
+
preceding_trip_id=pl.lit(None, dtype=pl.Int64),
|
|
91
|
+
next_trip_id=pl.col("trip_id"),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
intermediate_events = ordered_trips.filter(
|
|
95
|
+
pl.col("next_departure_datetime").is_not_null()
|
|
96
|
+
).select(
|
|
97
|
+
self._event_columns(
|
|
98
|
+
vehicle_id=pl.col("vehicle_id"),
|
|
99
|
+
person_id=pl.col("person_id"),
|
|
100
|
+
start_datetime=pl.col("arrival_datetime"),
|
|
101
|
+
end_datetime=pl.col("next_departure_datetime"),
|
|
102
|
+
location_id=pl.col("end_location_id"),
|
|
103
|
+
location_type=self._first_nonempty(
|
|
104
|
+
"following_purpose", "next_preceding_purpose"
|
|
105
|
+
),
|
|
106
|
+
preceding_trip_id=pl.col("trip_id"),
|
|
107
|
+
next_trip_id=pl.col("next_trip_id"),
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
final_events = ordered_trips.filter(
|
|
111
|
+
pl.col("next_trip_id").is_null()
|
|
112
|
+
& (pl.col("arrival_datetime") < simulation_end)
|
|
113
|
+
).select(
|
|
114
|
+
self._event_columns(
|
|
115
|
+
vehicle_id=pl.col("vehicle_id"),
|
|
116
|
+
person_id=pl.col("person_id"),
|
|
117
|
+
start_datetime=pl.col("arrival_datetime"),
|
|
118
|
+
end_datetime=simulation_end,
|
|
119
|
+
location_id=pl.col("end_location_id"),
|
|
120
|
+
location_type=self._first_nonempty("following_purpose"),
|
|
121
|
+
preceding_trip_id=pl.col("trip_id"),
|
|
122
|
+
next_trip_id=pl.lit(None, dtype=pl.Int64),
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
pl.concat([initial_events, intermediate_events, final_events])
|
|
128
|
+
.sort(["vehicle_id", "start_datetime"])
|
|
129
|
+
.with_row_index("parking_event_id")
|
|
130
|
+
.with_columns(pl.col("parking_event_id").cast(pl.Int64))
|
|
131
|
+
.select(
|
|
132
|
+
[
|
|
133
|
+
"parking_event_id",
|
|
134
|
+
"vehicle_id",
|
|
135
|
+
"person_id",
|
|
136
|
+
"start_datetime",
|
|
137
|
+
"end_datetime",
|
|
138
|
+
"parking_duration_s",
|
|
139
|
+
"location_id",
|
|
140
|
+
"location_type",
|
|
141
|
+
"preceding_trip_id",
|
|
142
|
+
"next_trip_id",
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _first_nonempty(*column_names: str) -> pl.Expr:
|
|
149
|
+
"""Return the first non-null, non-empty purpose or the ``other`` fallback."""
|
|
150
|
+
candidates = [
|
|
151
|
+
pl.when(
|
|
152
|
+
pl.col(column).is_not_null() & (pl.col(column).cast(pl.String) != "")
|
|
153
|
+
)
|
|
154
|
+
.then(pl.col(column).cast(pl.String))
|
|
155
|
+
.otherwise(None)
|
|
156
|
+
for column in column_names
|
|
157
|
+
]
|
|
158
|
+
return pl.coalesce([*candidates, pl.lit("other")])
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _event_columns(
|
|
162
|
+
*,
|
|
163
|
+
vehicle_id: pl.Expr,
|
|
164
|
+
person_id: pl.Expr,
|
|
165
|
+
start_datetime: pl.Expr,
|
|
166
|
+
end_datetime: pl.Expr,
|
|
167
|
+
location_id: pl.Expr,
|
|
168
|
+
location_type: pl.Expr,
|
|
169
|
+
preceding_trip_id: pl.Expr,
|
|
170
|
+
next_trip_id: pl.Expr,
|
|
171
|
+
) -> list[pl.Expr]:
|
|
172
|
+
"""Implement the internal event columns operation."""
|
|
173
|
+
duration_seconds = (
|
|
174
|
+
(end_datetime - start_datetime)
|
|
175
|
+
.dt.total_microseconds()
|
|
176
|
+
.truediv(1_000_000.0)
|
|
177
|
+
.alias("parking_duration_s")
|
|
178
|
+
)
|
|
179
|
+
return [
|
|
180
|
+
vehicle_id.cast(pl.String).alias("vehicle_id"),
|
|
181
|
+
person_id.cast(pl.Int64).alias("person_id"),
|
|
182
|
+
start_datetime.cast(pl.Datetime("us")).alias("start_datetime"),
|
|
183
|
+
end_datetime.cast(pl.Datetime("us")).alias("end_datetime"),
|
|
184
|
+
duration_seconds,
|
|
185
|
+
location_id.cast(pl.String).alias("location_id"),
|
|
186
|
+
location_type.cast(pl.String).alias("location_type"),
|
|
187
|
+
preceding_trip_id.cast(pl.Int64).alias("preceding_trip_id"),
|
|
188
|
+
next_trip_id.cast(pl.Int64).alias("next_trip_id"),
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _empty_parking_events() -> pl.DataFrame:
|
|
193
|
+
"""Implement the internal empty parking events operation."""
|
|
194
|
+
return pl.DataFrame(
|
|
195
|
+
schema={
|
|
196
|
+
"parking_event_id": pl.Int64,
|
|
197
|
+
"vehicle_id": pl.String,
|
|
198
|
+
"person_id": pl.Int64,
|
|
199
|
+
"start_datetime": pl.Datetime,
|
|
200
|
+
"end_datetime": pl.Datetime,
|
|
201
|
+
"parking_duration_s": pl.Float64,
|
|
202
|
+
"location_id": pl.String,
|
|
203
|
+
"location_type": pl.String,
|
|
204
|
+
"preceding_trip_id": pl.Int64,
|
|
205
|
+
"next_trip_id": pl.Int64,
|
|
206
|
+
}
|
|
207
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Normalize raw activity purposes to configured charging location types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
from mobev.core.config import Config
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ParkingLocationNormalizer:
|
|
15
|
+
"""Inputs: the global configuration and a parking-event table with raw
|
|
16
|
+
location labels.
|
|
17
|
+
|
|
18
|
+
Process: normalizes location labels to canonical categories, falls back to
|
|
19
|
+
`other` for unknown labels, and joins configured charger-access metadata onto each parking event.
|
|
20
|
+
|
|
21
|
+
Outputs: a parking-event table enriched with normalized location types and
|
|
22
|
+
location-level charging metadata.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, config: Config):
|
|
26
|
+
"""Implement the internal init operation."""
|
|
27
|
+
self.config = config
|
|
28
|
+
self._known_location_types = tuple(config.location_charging_configs)
|
|
29
|
+
self._location_metadata_df = pl.DataFrame(
|
|
30
|
+
{
|
|
31
|
+
"location_type": list(config.location_charging_configs),
|
|
32
|
+
"charger_availability_probability": [
|
|
33
|
+
location["charger_availability_probability"]
|
|
34
|
+
for location in config.location_charging_configs.values()
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def normalize(self, parking_df: pl.DataFrame) -> pl.DataFrame:
|
|
40
|
+
"""Return parking events with canonical location types and metadata."""
|
|
41
|
+
if parking_df.is_empty():
|
|
42
|
+
logger.warning(
|
|
43
|
+
"Received empty parking dataframe when normalizing locations."
|
|
44
|
+
)
|
|
45
|
+
return parking_df
|
|
46
|
+
|
|
47
|
+
logger.debug(
|
|
48
|
+
"Normalizing location metadata for %s parking events.", parking_df.height
|
|
49
|
+
)
|
|
50
|
+
normalized_location_expr = (
|
|
51
|
+
pl.col("location_type")
|
|
52
|
+
.cast(pl.Utf8, strict=False)
|
|
53
|
+
.fill_null("other")
|
|
54
|
+
.str.strip_chars()
|
|
55
|
+
.str.to_lowercase()
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
normalized_locations = parking_df.select(
|
|
59
|
+
normalized_location_expr.alias("location_type")
|
|
60
|
+
).unique()
|
|
61
|
+
unknown_locations = sorted(
|
|
62
|
+
normalized_locations.filter(
|
|
63
|
+
~pl.col("location_type").is_in(self._known_location_types)
|
|
64
|
+
)
|
|
65
|
+
.get_column("location_type")
|
|
66
|
+
.to_list()
|
|
67
|
+
)
|
|
68
|
+
if unknown_locations:
|
|
69
|
+
logger.debug(
|
|
70
|
+
"Unknown location types %s; falling back to 'other'.",
|
|
71
|
+
unknown_locations,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
resolved_df = parking_df.with_columns(
|
|
75
|
+
pl.when(normalized_location_expr.is_in(self._known_location_types))
|
|
76
|
+
.then(normalized_location_expr)
|
|
77
|
+
.otherwise(pl.lit("other"))
|
|
78
|
+
.alias("location_type")
|
|
79
|
+
).join(self._location_metadata_df, on="location_type", how="left")
|
|
80
|
+
logger.debug(
|
|
81
|
+
"Normalized location metadata for %s parking events.", resolved_df.height
|
|
82
|
+
)
|
|
83
|
+
return resolved_df
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Validate that each vehicle trip schedule is chronological and non-overlapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TripScheduleValidator:
|
|
13
|
+
"""Inputs: a trip table for the sampled EV population.
|
|
14
|
+
|
|
15
|
+
Process: sorts trips by person and departure time, checks that successive
|
|
16
|
+
trips do not overlap, and raises an error when an invalid schedule is found.
|
|
17
|
+
|
|
18
|
+
Outputs: the validated and chronologically ordered trip table used to derive
|
|
19
|
+
parking intervals.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def validate(self, trips_df: pl.DataFrame) -> pl.DataFrame:
|
|
23
|
+
"""Return the ordered trip schedule after overlap validation."""
|
|
24
|
+
if trips_df.is_empty():
|
|
25
|
+
logger.warning(
|
|
26
|
+
"Received empty trips dataframe when validating trip schedules."
|
|
27
|
+
)
|
|
28
|
+
return trips_df
|
|
29
|
+
|
|
30
|
+
logger.debug(
|
|
31
|
+
"Validating ordered trip schedules for %s trip rows.", trips_df.height
|
|
32
|
+
)
|
|
33
|
+
sorted_trips = trips_df.sort(["person_id", "departure_datetime", "trip_index"])
|
|
34
|
+
validation_df = sorted_trips.with_columns(
|
|
35
|
+
pl.col("departure_datetime")
|
|
36
|
+
.shift(-1)
|
|
37
|
+
.over("person_id")
|
|
38
|
+
.alias("next_departure_datetime")
|
|
39
|
+
)
|
|
40
|
+
overlap_count = validation_df.filter(
|
|
41
|
+
pl.col("next_departure_datetime").is_not_null()
|
|
42
|
+
& (pl.col("arrival_datetime") > pl.col("next_departure_datetime"))
|
|
43
|
+
).height
|
|
44
|
+
if overlap_count:
|
|
45
|
+
logger.error(
|
|
46
|
+
"Detected %s overlapping trip rows while validating schedules.",
|
|
47
|
+
overlap_count,
|
|
48
|
+
)
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"Trips overlap for at least one person; charging simulation requires ordered trip chains."
|
|
51
|
+
)
|
|
52
|
+
logger.debug("Validated %s trip schedule rows.", sorted_trips.height)
|
|
53
|
+
return sorted_trips
|