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/anddocs/dashboard.pngstay 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 notokens — Felix 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.
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.
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.
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.
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.
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.
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.
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 ingrid_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.pycovers 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 inresults/.
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.
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 abovepython 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.
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
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.
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.
