mobev 0.1.0__tar.gz

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.
Files changed (43) hide show
  1. mobev-0.1.0/LICENSE +25 -0
  2. mobev-0.1.0/PKG-INFO +196 -0
  3. mobev-0.1.0/README.md +172 -0
  4. mobev-0.1.0/mobev/__init__.py +0 -0
  5. mobev-0.1.0/mobev/charging/__init__.py +1 -0
  6. mobev-0.1.0/mobev/charging/charging_availability_assigner.py +187 -0
  7. mobev-0.1.0/mobev/charging/ev_fleet_sampler.py +140 -0
  8. mobev-0.1.0/mobev/charging/parking_interval_builder.py +207 -0
  9. mobev-0.1.0/mobev/charging/parking_location_normalizer.py +83 -0
  10. mobev-0.1.0/mobev/charging/trip_schedule_validator.py +53 -0
  11. mobev-0.1.0/mobev/core/charging_resolver.py +149 -0
  12. mobev-0.1.0/mobev/core/config.py +579 -0
  13. mobev-0.1.0/mobev/core/pipeline.py +129 -0
  14. mobev-0.1.0/mobev/core/profile_simulator.py +150 -0
  15. mobev-0.1.0/mobev/core/result.py +144 -0
  16. mobev-0.1.0/mobev/core/trip_preparer.py +142 -0
  17. mobev-0.1.0/mobev/load_profiles/__init__.py +1 -0
  18. mobev-0.1.0/mobev/load_profiles/battery_state_initializer.py +46 -0
  19. mobev-0.1.0/mobev/load_profiles/charging_session_builder.py +775 -0
  20. mobev-0.1.0/mobev/load_profiles/charging_start_planner.py +78 -0
  21. mobev-0.1.0/mobev/load_profiles/load_profile_aggregator.py +138 -0
  22. mobev-0.1.0/mobev/load_profiles/tariff_manager.py +442 -0
  23. mobev-0.1.0/mobev/trips/__init__.py +1 -0
  24. mobev-0.1.0/mobev/trips/trip_feature_builder.py +180 -0
  25. mobev-0.1.0/mobev/trips/trip_location_indexer.py +116 -0
  26. mobev-0.1.0/mobev/trips/weekly_trip_loader.py +134 -0
  27. mobev-0.1.0/mobev/utils/logger.py +96 -0
  28. mobev-0.1.0/mobev/utils/plot_profile.py +1132 -0
  29. mobev-0.1.0/mobev/utils/plot_trips.py +365 -0
  30. mobev-0.1.0/mobev/utils/results_indicators.py +132 -0
  31. mobev-0.1.0/mobev.egg-info/PKG-INFO +196 -0
  32. mobev-0.1.0/mobev.egg-info/SOURCES.txt +41 -0
  33. mobev-0.1.0/mobev.egg-info/dependency_links.txt +1 -0
  34. mobev-0.1.0/mobev.egg-info/requires.txt +9 -0
  35. mobev-0.1.0/mobev.egg-info/top_level.txt +1 -0
  36. mobev-0.1.0/pyproject.toml +63 -0
  37. mobev-0.1.0/setup.cfg +4 -0
  38. mobev-0.1.0/tests/test_charging_pipeline.py +136 -0
  39. mobev-0.1.0/tests/test_config.py +95 -0
  40. mobev-0.1.0/tests/test_pipeline.py +72 -0
  41. mobev-0.1.0/tests/test_profile_pipeline.py +125 -0
  42. mobev-0.1.0/tests/test_tariff_pipeline.py +51 -0
  43. mobev-0.1.0/tests/test_trip_pipeline.py +99 -0
mobev-0.1.0/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ The MIT License (MIT)
2
+ =====================
3
+
4
+ - Copyright © `2026` `Yoann CHICHE`
5
+
6
+ Permission is hereby granted, free of charge, to any person
7
+ obtaining a copy of this software and associated documentation
8
+ files (the “Software”), to deal in the Software without
9
+ restriction, including without limitation the rights to use,
10
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the
12
+ Software is furnished to do so, subject to the following
13
+ conditions:
14
+
15
+ The above copyright notice and this permission notice shall be
16
+ included in all copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
19
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
20
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
22
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
23
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
24
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
25
+ OTHER DEALINGS IN THE SOFTWARE.
mobev-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.4
2
+ Name: mobev
3
+ Version: 0.1.0
4
+ Summary: MobEV: mobility-based, geolocalized EV charging-demand model.
5
+ Author-email: Yoann Chiche <yoann.chiche@minesparis.psl.eu>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://git.persee.minesparis.psl.eu/planeterr/mobev
8
+ Project-URL: Documentation, https://pages.persee.minesparis.psl.eu/planeterr/mobev/
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.13
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: pandas
15
+ Requires-Dist: numpy
16
+ Requires-Dist: geopandas
17
+ Requires-Dist: shapely>=2
18
+ Requires-Dist: polars
19
+ Requires-Dist: PyYAML
20
+ Requires-Dist: matplotlib
21
+ Requires-Dist: pyarrow
22
+ Requires-Dist: colorlog
23
+ Dynamic: license-file
24
+
25
+ # MobEV
26
+
27
+ [![Python](https://img.shields.io/badge/python-3.13%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
28
+ [![PyPI version](https://img.shields.io/pypi/v/mobev)](https://pypi.org/project/mobev/)
29
+ [![Package manager](https://img.shields.io/badge/package%20manager-uv-6E56CF?logo=uv&logoColor=white)](https://docs.astral.sh/uv/)
30
+ [![Tests](https://img.shields.io/badge/tests-pytest-0A9EDC?logo=pytest&logoColor=white)](https://git.persee.minesparis.psl.eu/planeterr/mobev/-/tree/main/tests)
31
+ [![Documentation](https://img.shields.io/badge/docs-Sphinx-4B8BBE?logo=readthedocs&logoColor=white)](https://pages.persee.minesparis.psl.eu/planeterr/mobev/)
32
+
33
+ MobEV is a mobility-based, geolocalized EV charging-demand model. It
34
+ generates EV charging-demand profiles for distribution-network studies from
35
+ weekly car-trip data. The model samples an EV fleet and charging access,
36
+ simulates battery state of charge and charging sessions, and aggregates the
37
+ resulting demand at the configured timestep.
38
+
39
+ Full reference documentation: **[https://pages.persee.minesparis.psl.eu/planeterr/mobev/](https://pages.persee.minesparis.psl.eu/planeterr/mobev/)**.
40
+
41
+ ## Requirements and installation
42
+
43
+ MobEV requires Python 3.13 or newer.
44
+
45
+ Install the published package with pip:
46
+
47
+ ```bash
48
+ python -m pip install mobev
49
+ ```
50
+
51
+ Or add it to an existing uv-managed project:
52
+
53
+ ```bash
54
+ uv add mobev
55
+ ```
56
+
57
+ For development from a repository checkout, use the included lock file:
58
+
59
+ ```bash
60
+ uv sync
61
+ ```
62
+
63
+ To build the documentation locally:
64
+
65
+ ```bash
66
+ uv sync --group docs
67
+ make -C docs html
68
+ ```
69
+
70
+ ## Inputs
71
+
72
+ Each simulation needs a trip directory containing:
73
+
74
+ ```text
75
+ weekday_trips.gpkg
76
+ weekend_trips.gpkg
77
+ ```
78
+
79
+ The source data must provide person and activity identifiers, departure and
80
+ arrival times, activity purposes, car mode, and trip geometries. They are generated using a modified version of the EQASIM tool : <https://github.com/RatonMagique/eqasim-france/tree/feat/iris-commune-weekday-pipeline-improvments> . See
81
+ [input documentation](https://pages.persee.minesparis.psl.eu/planeterr/mobev/inputs.html) for the complete input
82
+ schema.
83
+
84
+ When tariff-aware charging is enabled, the tariff CSV must contain a
85
+ contiguous integer index and one numeric `price` or `price_eur_kwh` column.
86
+
87
+ ## Configuration
88
+
89
+ The authoritative configuration template is available in the
90
+ [configuration documentation](https://pages.persee.minesparis.psl.eu/planeterr/mobev/configuration.html).
91
+ Repository users can copy [config/config.yaml](https://git.persee.minesparis.psl.eu/planeterr/mobev/-/blob/main/config/config.yaml)
92
+ when maintaining separate experiments, then pass the copy to the pipeline.
93
+ The repository template uses project-relative data paths. When using the
94
+ installed package, provide an absolute path for `tariff.file` (or disable the
95
+ tariff) and pass the trip-directory path explicitly.
96
+
97
+ The main sections are:
98
+
99
+ - `simulation`: timestep, start datetime, random seed, distance factor, CRS,
100
+ and seasonal consumption.
101
+ - `fleet`: EV penetration and fixed or segmented vehicle attributes.
102
+ - `battery`: usable SOC limits, charging efficiency, and initial SOC.
103
+ - `charging`: minimum parking duration and SOC thresholds.
104
+ - `tariff`: optional time-of-use charging and tariff CSV settings.
105
+ - `locations`: charger availability and charger-power distributions by
106
+ location type.
107
+
108
+ Edit the YAML values directly, for example:
109
+
110
+ ```yaml
111
+ simulation:
112
+ timestep_minutes: 15
113
+ random_seed: 42
114
+
115
+ fleet:
116
+ ev_penetration: 0.10
117
+
118
+ tariff:
119
+ enabled: false
120
+ file: path/to/tariff.csv
121
+ ```
122
+
123
+ Set `tariff.enabled` to `true` only when `tariff.file` points to a valid CSV.
124
+ The random seed makes fleet sampling, charger assignment, and initial SOC
125
+ reproducible for the same inputs and dependency versions.
126
+
127
+ ## Run the complete pipeline
128
+
129
+ The main entry point is `SimulationPipeline`. It loads the configuration and
130
+ runs all three stages in order: trip preparation, charging resolution, and
131
+ load-profile simulation.
132
+
133
+ From Python:
134
+
135
+ ```python
136
+ from mobev.core.pipeline import SimulationPipeline
137
+
138
+ pipeline = SimulationPipeline.from_yaml("config/config.yaml")
139
+ result = pipeline.run("data/input/trips/YOUR_FEEDER")
140
+
141
+ print(result.load_timeseries_df)
142
+ print(result.tariff_curve_df) # empty when tariffs are disabled
143
+ ```
144
+
145
+ The checked-in runner can also be used after updating `config_file` and
146
+ `trip_folder` in [scripts/run_pipeline.py](https://git.persee.minesparis.psl.eu/planeterr/mobev/-/blob/main/scripts/run_pipeline.py):
147
+
148
+ ```bash
149
+ python scripts/run_pipeline.py
150
+ ```
151
+
152
+ `EVSimulationResult` exposes all intermediate and final tables, including
153
+ trips, locations, vehicles, parking events, charging stations, charging
154
+ opportunities, SOC traces, charging sessions, load timeseries, and the tariff
155
+ curve.
156
+
157
+ ## Run a decomposed pipeline
158
+
159
+ Run the stages separately when you need to inspect or persist intermediate
160
+ results, customize a stage, or integrate MobEV into a larger workflow:
161
+
162
+ ```python
163
+ from mobev.core.charging_resolver import ChargingResolver
164
+ from mobev.core.config import Config
165
+ from mobev.core.profile_simulator import ProfileSimulator
166
+ from mobev.core.trip_preparer import TripPreparer
167
+
168
+ config = Config.from_yaml("config/config.yaml")
169
+ trip_results = TripPreparer(config).run("data/input/trips/YOUR_FEEDER")
170
+ charging_results = ChargingResolver(config).run(trip_results)
171
+ profile_results = ProfileSimulator(config).run(trip_results, charging_results)
172
+
173
+ print(profile_results.load_timeseries_df)
174
+ ```
175
+
176
+ The stage contracts are:
177
+
178
+ ```text
179
+ TripPreparer -> TripResults
180
+ ChargingResolver -> ChargingResults
181
+ ProfileSimulator -> ProfileResults
182
+ ```
183
+
184
+ ## Testing
185
+
186
+ Run the test suite with:
187
+
188
+ ```bash
189
+ .venv/bin/pytest -q
190
+ ```
191
+
192
+
193
+ ## Further documentation
194
+
195
+ The [Sphinx documentation](https://pages.persee.minesparis.psl.eu/planeterr/mobev/) covers inputs,
196
+ configuration, workflow, tariffs, results, visualization, and the public API.
mobev-0.1.0/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # MobEV
2
+
3
+ [![Python](https://img.shields.io/badge/python-3.13%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
4
+ [![PyPI version](https://img.shields.io/pypi/v/mobev)](https://pypi.org/project/mobev/)
5
+ [![Package manager](https://img.shields.io/badge/package%20manager-uv-6E56CF?logo=uv&logoColor=white)](https://docs.astral.sh/uv/)
6
+ [![Tests](https://img.shields.io/badge/tests-pytest-0A9EDC?logo=pytest&logoColor=white)](https://git.persee.minesparis.psl.eu/planeterr/mobev/-/tree/main/tests)
7
+ [![Documentation](https://img.shields.io/badge/docs-Sphinx-4B8BBE?logo=readthedocs&logoColor=white)](https://pages.persee.minesparis.psl.eu/planeterr/mobev/)
8
+
9
+ MobEV is a mobility-based, geolocalized EV charging-demand model. It
10
+ generates EV charging-demand profiles for distribution-network studies from
11
+ weekly car-trip data. The model samples an EV fleet and charging access,
12
+ simulates battery state of charge and charging sessions, and aggregates the
13
+ resulting demand at the configured timestep.
14
+
15
+ Full reference documentation: **[https://pages.persee.minesparis.psl.eu/planeterr/mobev/](https://pages.persee.minesparis.psl.eu/planeterr/mobev/)**.
16
+
17
+ ## Requirements and installation
18
+
19
+ MobEV requires Python 3.13 or newer.
20
+
21
+ Install the published package with pip:
22
+
23
+ ```bash
24
+ python -m pip install mobev
25
+ ```
26
+
27
+ Or add it to an existing uv-managed project:
28
+
29
+ ```bash
30
+ uv add mobev
31
+ ```
32
+
33
+ For development from a repository checkout, use the included lock file:
34
+
35
+ ```bash
36
+ uv sync
37
+ ```
38
+
39
+ To build the documentation locally:
40
+
41
+ ```bash
42
+ uv sync --group docs
43
+ make -C docs html
44
+ ```
45
+
46
+ ## Inputs
47
+
48
+ Each simulation needs a trip directory containing:
49
+
50
+ ```text
51
+ weekday_trips.gpkg
52
+ weekend_trips.gpkg
53
+ ```
54
+
55
+ The source data must provide person and activity identifiers, departure and
56
+ arrival times, activity purposes, car mode, and trip geometries. They are generated using a modified version of the EQASIM tool : <https://github.com/RatonMagique/eqasim-france/tree/feat/iris-commune-weekday-pipeline-improvments> . See
57
+ [input documentation](https://pages.persee.minesparis.psl.eu/planeterr/mobev/inputs.html) for the complete input
58
+ schema.
59
+
60
+ When tariff-aware charging is enabled, the tariff CSV must contain a
61
+ contiguous integer index and one numeric `price` or `price_eur_kwh` column.
62
+
63
+ ## Configuration
64
+
65
+ The authoritative configuration template is available in the
66
+ [configuration documentation](https://pages.persee.minesparis.psl.eu/planeterr/mobev/configuration.html).
67
+ Repository users can copy [config/config.yaml](https://git.persee.minesparis.psl.eu/planeterr/mobev/-/blob/main/config/config.yaml)
68
+ when maintaining separate experiments, then pass the copy to the pipeline.
69
+ The repository template uses project-relative data paths. When using the
70
+ installed package, provide an absolute path for `tariff.file` (or disable the
71
+ tariff) and pass the trip-directory path explicitly.
72
+
73
+ The main sections are:
74
+
75
+ - `simulation`: timestep, start datetime, random seed, distance factor, CRS,
76
+ and seasonal consumption.
77
+ - `fleet`: EV penetration and fixed or segmented vehicle attributes.
78
+ - `battery`: usable SOC limits, charging efficiency, and initial SOC.
79
+ - `charging`: minimum parking duration and SOC thresholds.
80
+ - `tariff`: optional time-of-use charging and tariff CSV settings.
81
+ - `locations`: charger availability and charger-power distributions by
82
+ location type.
83
+
84
+ Edit the YAML values directly, for example:
85
+
86
+ ```yaml
87
+ simulation:
88
+ timestep_minutes: 15
89
+ random_seed: 42
90
+
91
+ fleet:
92
+ ev_penetration: 0.10
93
+
94
+ tariff:
95
+ enabled: false
96
+ file: path/to/tariff.csv
97
+ ```
98
+
99
+ Set `tariff.enabled` to `true` only when `tariff.file` points to a valid CSV.
100
+ The random seed makes fleet sampling, charger assignment, and initial SOC
101
+ reproducible for the same inputs and dependency versions.
102
+
103
+ ## Run the complete pipeline
104
+
105
+ The main entry point is `SimulationPipeline`. It loads the configuration and
106
+ runs all three stages in order: trip preparation, charging resolution, and
107
+ load-profile simulation.
108
+
109
+ From Python:
110
+
111
+ ```python
112
+ from mobev.core.pipeline import SimulationPipeline
113
+
114
+ pipeline = SimulationPipeline.from_yaml("config/config.yaml")
115
+ result = pipeline.run("data/input/trips/YOUR_FEEDER")
116
+
117
+ print(result.load_timeseries_df)
118
+ print(result.tariff_curve_df) # empty when tariffs are disabled
119
+ ```
120
+
121
+ The checked-in runner can also be used after updating `config_file` and
122
+ `trip_folder` in [scripts/run_pipeline.py](https://git.persee.minesparis.psl.eu/planeterr/mobev/-/blob/main/scripts/run_pipeline.py):
123
+
124
+ ```bash
125
+ python scripts/run_pipeline.py
126
+ ```
127
+
128
+ `EVSimulationResult` exposes all intermediate and final tables, including
129
+ trips, locations, vehicles, parking events, charging stations, charging
130
+ opportunities, SOC traces, charging sessions, load timeseries, and the tariff
131
+ curve.
132
+
133
+ ## Run a decomposed pipeline
134
+
135
+ Run the stages separately when you need to inspect or persist intermediate
136
+ results, customize a stage, or integrate MobEV into a larger workflow:
137
+
138
+ ```python
139
+ from mobev.core.charging_resolver import ChargingResolver
140
+ from mobev.core.config import Config
141
+ from mobev.core.profile_simulator import ProfileSimulator
142
+ from mobev.core.trip_preparer import TripPreparer
143
+
144
+ config = Config.from_yaml("config/config.yaml")
145
+ trip_results = TripPreparer(config).run("data/input/trips/YOUR_FEEDER")
146
+ charging_results = ChargingResolver(config).run(trip_results)
147
+ profile_results = ProfileSimulator(config).run(trip_results, charging_results)
148
+
149
+ print(profile_results.load_timeseries_df)
150
+ ```
151
+
152
+ The stage contracts are:
153
+
154
+ ```text
155
+ TripPreparer -> TripResults
156
+ ChargingResolver -> ChargingResults
157
+ ProfileSimulator -> ProfileResults
158
+ ```
159
+
160
+ ## Testing
161
+
162
+ Run the test suite with:
163
+
164
+ ```bash
165
+ .venv/bin/pytest -q
166
+ ```
167
+
168
+
169
+ ## Further documentation
170
+
171
+ The [Sphinx documentation](https://pages.persee.minesparis.psl.eu/planeterr/mobev/) covers inputs,
172
+ configuration, workflow, tariffs, results, visualization, and the public API.
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}")