Skip to content

Repository files navigation

VoltPlan — planning EV charging infrastructure for Prague

Python Polars LightGBM scikit-learn Streamlit Licence CI Scheduler tests Dataset Results

2,895 modelled zones · 55 leakage-free features · 2.29M rows of hourly data across 3,408 zones

Read the case study — problem framing, modelling decisions, review findings, results, and what I would test next.

Team project — see Credits. The supplied task brief identifies nvias, z.s. and CC BY-NC-SA 4.0; the dataset is not redistributed here, and the derived files in results/ and docs/dashboard.png stay under that licence too, see Licence.

Track A — demand prediction MAE 24.03 kWh — 38.8% better than the population baseline, 17.0% better than a linear model on the same features
Track B — solution type 84.7% accuracy against a 38.7% baseline, +46.0 points
What the label actually is reverse-engineered the organisers' labelling rule — 99.75% of it recovered from three columns
Managed charging only 3 of 38 grid overloads trace back to EV charging; each deficit block fits later within 6 hours
Scope and limits what the synthetic dataset does and does not support

For every zone of Prague — a piece of the city hanging off one transformer station — VoltPlan predicts 2030 EV charging demand, recommends a charging solution type, and replays four representative weeks of 2025 grid data to see whether delaying charging keeps those transformers under their limit.

Built at the Czech AI Olympiad 2026 (task AIO_PHA-02-PHA, Prague regional round) by team notokensFelix Cumarav, Vojtěch Halák and Timofej Golobokov — placing 2nd, which took the team through to the national final, where the top three from the region advanced. The final set a different task, so VoltPlan itself was not carried into it. Since then it has been rewritten from a four-hour hackathon state into an auditable pipeline. A holder of the exact dataset archive identified by checksum in THIRD_PARTY_NOTICES.md can rerun it; that statement does not extend to a different dataset copy or promise byte-identical outputs.

What the rewrite added: every number regenerated from the data and checked against the outputs it claims to come from · a leakage guard that keeps all 55 features to measured or derived columns · three baselines instead of one, including a linear model on the identical features · an evaluation leak found in the competition code and fixed · the labelling rule behind Track B recovered and measured · audit trails for every scheduled deficit block · fixed seeds on both models. The published outputs were produced with the versions listed below; those seeds improve repeatability, but dependencies are not fully pinned, LightGBM's deterministic flag is not set, and a couple of sorts have ties. This repository therefore makes no bit-for-bit reproduction claim.

The dataset is a modelling exercise: the 2030 targets and the hourly grid load are _synthetic, generated by the organisers. That shapes what any result here can mean, and Scope and limits spells it out.


Track A — demand prediction

src/train_demand.py, LightGBM. 2,378 training zones, 517 validation zones.

model MAE (kWh) RMSE (kWh) Precision@50
VoltPlan (LightGBM) 24.03 39.68 0.78
Ridge, same 55 features 28.97 43.85 0.84
population baseline 39.29 57.85 0.76
mean baseline 119.18 156.37 n/a

Every prediction from the mean baseline is the same number, so ranking the top 50 zones by it picks 50 arbitrary rows — reversing the input changes the score from 0.06 to 0.14. It is reported as n/a rather than dressed up as a result.

38.8% lower MAE than the population baseline — the "demand is proportional to residents" rule of thumb that is the usual first guess here — and 17.0% lower than a ridge regression on the identical feature set. That second comparison is the one that matters: it shows the gradient boosting is doing work the features alone do not. All baselines are fitted on the training zones only, so they play by the same rules as the model.

Two things worth reading off the table rather than past it. Re-seeding moves the MAE by a few tenths of a kWh, so read it as a ~24 kWh model rather than a 24.03 kWh one — the exact spread depends on which seeds you pick and this repository does not persist that sweep, so treat the second decimal as noise rather than as a measured interval. And the ranking metric behaves differently from the error metric: on Precision@50 — how many of the 50 busiest zones the model finds, which is the shortlist a planner actually works from — the linear model reaches 0.84 against the model's 0.78. Error magnitude and ranking are different jobs, and this model is tuned for the first. Reporting both is how you know that.

By split count the model leans hardest on destination activity, 2021 census population and census housing stock, followed by the organisers' own population model input and the index of flats without private parking — households with nowhere to charge at home. Split counts favour high-cardinality columns, so read the ordering as a sanity check rather than an effect size.

All 55 features are _real or _derived. Every _synthetic column stays out — targets and the organisers' modelled 2025 grid inputs alike — which is what keeps the task non-trivial. target_recommended_total_kw_synthetic is the clearest example: it takes six values (0, 22, 66, 100, 600, 900 kW) and those map one to one onto Track B's six solution classes, so including it would hand that task its own answer. For Track A it is looser but still leakage — it is derived from the demand the model is supposed to predict, and one power value can cover a wide band of it.

Evaluation integrity

The competition version ran LightGBM early stopping against zones_validation.csv — the same 517 zones it then reported MAE on. The tree count was being chosen on the measurement set, which makes any score optimistic by construction. It is an easy mistake to ship and a hard one to spot after the fact.

It is gone: the tree budget is a fixed constant and nothing is tuned on validation, so the number below is a clean holdout measurement. Correcting it moved the reported MAE from 23.57 to 24.03 kWh — a difference of the same order as the seed spread above, which is the point. The method changed; the model did not get worse.

Track B — recommending a solution type

src/match_v2.py, classification into the 6 types in candidate_solutions.csv.

model accuracy
VoltPlan 84.7%
baseline (most frequent class) 38.7%

46.0 percentage points above the baseline, with the class breakdown that aggregate accuracy would hide:

solution type support precision recall F1
none_monitor 59 0.92 0.93 0.92
residential_ac_small 145 0.92 0.83 0.87
residential_ac_medium 200 0.86 0.93 0.89
destination_dc50 36 0.73 0.89 0.80
mixed_mobility_hub 34 0.88 0.62 0.72
fast_hub_150 43 0.59 0.56 0.57

Four of the six classes land at F1 0.80 or better. The weakest is fast_hub_150 at 0.57 — a high-power build, though the dataset carries no costs, so which class is dearest is not something this repository can tell you. The per-class breakdown ships with the model either way, so a human can see which recommendations to check before anyone commits capital.

What the label actually is

src/rule_discovery.py

This is the part of the project I would show first. The recommended solution type looks like expert judgement, and treating it that way would have been the comfortable choice. It is not: it is a threshold rule the organisers applied to their own synthetic demand column, and it can be recovered from the data.

Reading the demand band of each class straight off the training set shows the ladder:

solution type zones demand band (kWh/day)
none_monitor 225 3.0 – 34.9
residential_ac_small 635 35.0 – 105.0
destination_dc50 153 105.1 – 310.9
residential_ac_medium 1,019 105.1 – 775.7
fast_hub_150 190 315.1 – 452.4
mixed_mobility_hub 156 441.2 – 1,681.1

Cuts at roughly 35 / 105 / 315 / 440 kWh, with destination-flavoured classes splitting off from residential ones through destination_activity_index_derived and residential_index_derived. The bands are not perfectly disjoint — the two residential classes span several cuts, because the residential/destination split happens first — so the ladder is the shape of the rule, not the rule itself.

How completely three columns explain the label:

tree depth leaves training labels reproduced
4 10 97.01%
8 (shipped) 36 99.75%
unrestricted 43 100.00%

A 10-leaf tree over (2030 demand, destination index, residential index) already accounts for 97% of the labels, and 36 leaves get 99.75%. Labels that came from human judgement do not compress like that. The depth is capped deliberately — an unrestricted tree fits any labelling perfectly, so 100% would prove nothing.

Two things follow, and both change how every accuracy on this page should be read:

Track B is Track A plus a rule. Two of the rule's three inputs are ordinary _derived features the classifier already sees. The only thing withheld is the 2030 demand — so this is not an independent problem, it is demand estimation in different clothing.

Most of the remaining error is demand estimation, not classification. Hand the recovered rule the true 2030 demand and it scores 98.1%; hand the same oracle demand to the classifier and it scores 99.2%. The gap between those and 84.7% is almost entirely the cost of not knowing 2030.

approach accuracy knows true demand
classifier given true 2030 demand 99.2% yes
recovered rule given true 2030 demand 98.1% yes
two-stage: predict demand, then apply the rule 86.1% no
direct classification (match_v2.py) 84.7% no
baseline: always the most frequent class 38.7% no

The two oracle rows are reference points, not achievable results — a new zone does not come with its 2030 demand. Note also that the rule's 98.1% is not an upper bound, since a stronger model with the same information beats it; this README deliberately does not call it a ceiling.

The two-stage pipeline measures 1.4 points above the shipped classifier — seven zones out of 517, on a single split, so it is not a result worth picking a winner from. The direct classifier ships anyway: it only needs labels to be retrained, while the two-stage pipeline leans on a recovered rule and would break quietly if the organisers moved a threshold. Shipping the more robust of two statistically tied options is the whole decision.

Knowing this makes the product description precise rather than smaller: it estimates demand and reproduces the planning rule — which is exactly what is useful, because a new zone has no 2030 demand figure either.

Managed charging

src/managed_charging.py, a scheduler over 2.29M rows read lazily with Polars.

It finds the zone-hours where EV charging is what pushed a transformer past its limit. For each one, it computes only the power deficit needed to return the source hour to zero headroom (-available_capacity_after_ev), then treats that deficit as one block and places it in the nearest later hour in the same zone with enough spare capacity inside the delay window. It does not move the whole charging event or the source hour's full EV load. Target headroom is decremented as blocks are assigned, and the script refuses to finish if any target hour ended up below zero.

With a 6-hour window, across four representative weeks of 2025:

overloaded zone-hours, all causes 38
of those, caused by EV charging 3 (all in one zone)
EV-caused deficit represented as one-hour blocks 2.911 kWh
scheduled 2.911 kWh
unresolved 0

In this replay, placing each deficit block later returns all three EV-caused source hours to zero headroom without creating a target-hour overload. But the more valuable result is the one behind it: only 3 of 38 overloads trace back to EV charging at all. The other 35 sit in 7 zones that were already past their limit before any EV load was added; 27 of those hours have zero EV charging. Across all 35 non-EV overload hours, the persisted deficit is 228.2 kWh.

For a city, that reframes the question. Under this deficit-block replay, delay removes the EV-caused overloads but cannot touch the overloads already present before EV load. Distinguishing those buckets before deeper engineering analysis is the point of the exercise.

What this greedy replay does and does not model:

  • A block is an overload deficit, not a charging event. The scheduler moves just enough power to remove the EV-caused deficit at the source hour. It does not infer sessions or preserve a vehicle-level charging profile.
  • Each deficit block gets one target hour. The nearest feasible later hour wins; the block is not split across several hours. A block with no single feasible target inside the configured window is recorded as unresolved. The persisted schedule exposes the source hour, target hour, deficit power moved, and target headroom before and after each assignment. A separate file records deficit blocks that could not be placed.

On this dataset the three events never compete for the same hour, so the contention logic never runs in production. tests/test_scheduler.py exercises it on hand-built frames instead — including two overloads wanting one hour that only has room for one of them.

Dashboard

The VoltPlan dashboard: a map of Prague zones sized by predicted demand, validation metrics, and the detail panel for a selected zone

src/app.py, Streamlit. Zone map, filters, per-zone detail, both models' results and the shift audit table. Every metric and count on the page is read from files the pipeline actually produced — when they are missing it prints the commands to generate them rather than rendering plausible-looking numbers. The screenshot above is the app running on the outputs in results/, not a mockup.

The UI is in Czech because its intended reader is a Prague city planner. Each zone panel ends with the caveat that the recommendation comes from a classifier trained on the organisers' rule and needs the distribution operator's sign-off before anyone spends money.


Scope and limits

The dataset is a modelling exercise built by the competition organisers, and that determines what the results above can and cannot claim.

  • The 2030 targets are _synthetic. The models reproduce the organisers' generator, not the future. The metrics measure how well that is done.
  • Track B reproduces a threshold rule rather than issuing an independent engineering recommendation, as shown above. It does not replace a distribution operator's assessment.
  • The hourly grid data is modelled too. Load, reserve, available capacity and the overload flag are all _synthetic — only the weather columns are measured — as are the transformer ratings in grid_capacity_and_reserve_2025.csv.
  • It is four weeks, not a year: one representative week per season, 4 × 168 hours across 3,408 zones.
  • Delay, not V2G. Energy only moves later in time; nothing is pushed from a car back into the grid.
  • No grid optimisation. MILP siting exists in the original concept document, not in this code.
  • No ROI figures. The hackathon version had them, they were not computed from anything, and they are gone.
  • Testing is targeted, not broad. tests/test_scheduler.py covers the load-shifting rules — the one place the dataset cannot demonstrate the behaviour the code claims. The rest rests on runtime invariants and the audit outputs in results/.

Data

The dataset ships with the competition task, whose supplied brief identifies nvias, z.s. and CC BY-NC-SA 4.0. It is not committed here — 263 MB unpacked — so the schema is documented in docs/DATA.md instead, and the code reads without it. The scripts expect it unpacked at data/participants/core/ and say so rather than crashing if it is absent.

results/ holds the derived outputs that are in the repo: metrics, validation predictions, feature importances, the rule-discovery tables and the shift audit. The two prediction files carry organiser-supplied columns alongside the model's own — zone ids, real centroids and the synthetic reference values for the 517 validation zones — so they stay under the dataset's licence; see Licence.

Running it

Python 3.11 with polars, numpy, scikit-learn, lightgbm, plus streamlit and pydeck for the dashboard. Produced with polars 1.43, lightgbm 4.7, scikit-learn 1.9, streamlit 1.61.

pip install -r requirements.txt

python tests/test_scheduler.py  # scheduler rules, no dataset needed

python src/train_demand.py      # Track A — demand + three baselines
python src/match_v2.py          # Track B — solution type
python src/rule_discovery.py    # what the label is, and what it costs
python src/managed_charging.py  # load-shifting replay
streamlit run src/app.py        # dashboard over the outputs above

python src/train_demand.py --list shows the other numeric targets that can be predicted with --target; output columns are named after whichever target is used.

Layout

src/train_demand.py       Track A — demand prediction + three baselines
src/match_v2.py           Track B — solution type classification
src/rule_discovery.py     reverse-engineers the label, measures what it costs
src/managed_charging.py   capacity-constrained load-shifting replay
src/app.py                Streamlit dashboard over real outputs only
src/profile_data.py       dataset profiling and target-column report
tests/test_scheduler.py   load-shifting rules, runs without the dataset
tests/check_readme_claims.py  the numbers below, read back out of results/
results/                  metrics, validation predictions, audit trails
docs/DATA.md              dataset description

Licence

The code is MIT (LICENSE).

The supplied task brief identifies nvias, z.s. and CC BY-NC-SA 4.0. The 263 MB unpacked dataset is not distributed here. The local archive used for the published outputs is identified by checksum in THIRD_PARTY_NOTICES.md; its acquisition URL was not preserved and is explicitly recorded there as missing.

Everything in results/ and docs/dashboard.png is a different matter and worth being exact about. Those are adapted material derived from that dataset, and they carry columns that come straight out of it:

  • zone identifiers and the real zone centroids;
  • the organisers' synthetic reference values for the 517-zone validation split;
  • the charger catalogue fields (catalogue_ports, catalogue_total_power_kw, catalogue_daily_energy_capacity_kwh, catalogue_suitable_context);
  • the zone ids, timestamps and synthetic capacities in the shift schedule;
  • and the screenshot, which renders 517 of those zones on a map.

The screenshot's basemap is a separate matter again: map data © OpenStreetMap contributors under the Open Database License, tiles © CARTO. Those terms cover the basemap; the zones drawn on it are the nvias-derived part.

All of it remains under CC BY-NC-SA 4.0, not MIT. Sharing it here is what that licence allows — non-commercially, with attribution to nvias, z.s., and under the same terms — and it is included because it is what lets the dashboard run for someone who does not have the dataset.

See THIRD_PARTY_NOTICES.md for the provenance record and the separate map attribution.

Credits

Built at the competition by team notokens: Felix Cumarav, Vojtěch Halák and Timofej Golobokov. Second place in the Prague regional round was the team's, not one person's.

Within it, the modelling work in this repository — Track A and Track B — was mine.

The post-competition rewrite is mine: re-running every number from the data, removing the fabricated V2G and ROI claims the hackathon version carried, correcting the early-stopping leak in the evaluation, adding the same-features baseline, and reverse-engineering how the label was generated.

About

Planning EV charging infrastructure for Prague: 2030 demand prediction, charging solution type classification, and a capacity-constrained load-shifting replay.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages