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 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