Predictive Location Optimization & Grid Impact Model for the Greater Toronto Area. Built for the Seneca Hackathon 2026 by Team CXC.
EV adoption is accelerating, but utilities don't know where charging demand will spike. This system simulates 300,000 electric vehicles across the GTA using agent-based Monte Carlo simulation with a Semi-Markov decision layer, identifies which neighborhoods will overload the power grid, and prescribes exactly where to build new charging infrastructure.
Three-view story arc:
- Energy Spikes — heatmap showing where EV charging demand concentrates
- Grid Vulnerability — which neighborhoods turn red as the grid fails
- Optimal Placement — where to build chargers to prevent blackouts
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env
uv sync
uv run uvicorn backend.apis.api:app --reload --port 8000API docs at http://localhost:8000/docs.
cd frontend
npm install
npm run devOpens at http://localhost:5173.
uv run streamlit run app.pyOpens at http://localhost:8501.
┌─────────────────────────────────────────────────────────────────────┐
│ Data Sources │
│ StatsCan FSA Boundaries · Toronto Hydro Feeders · Hydro One │
│ IESO Load Profiles · City of Toronto Traffic · AFDC Chargers │
│ OSM Road Network (145K nodes, 383K edges) │
└──────────────────────────┬──────────────────────────────────────────┘
│
┌────────────▼────────────┐
│ Phase 1: Spatial │
│ Assembler │
│ 260 FSA zones │
│ Real grid capacity │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Phase 2: Monte Carlo │
│ + Semi-Markov Layer │
│ 300K agents │
│ 3.5M weekly trips │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Phase 3: MILP │
│ Optimizer │
│ Facility Location │
│ Problem (PuLP) │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Phase 4: Dashboard │
│ React + Leaflet │
│ Streamlit + Folium │
└─────────────────────────┘
Loads 260 GTA postal code (FSA) boundaries from Statistics Canada, classifies each by land-use type, and assigns grid capacity from real utility data.
Proxy capacity (fallback):
| Zone Type | Capacity | Examples |
|---|---|---|
| Residential | 300 kW | Suburban neighborhoods |
| Leisure | 500 kW | Parks, museums |
| Office Park | 1,200 kW | Corporate buildings |
| Retail Hub | 1,500 kW | Shopping centers |
| Transit Hub | 3,000 kW | Airports |
Real utility overlays:
- Toronto Hydro — ArcGIS spatial join maps real feeder capacity to 102 M-prefix (Toronto core) FSAs. Uses
min(overlapping_feeder_capacities)as the weakest-link constraint. - Hydro One — Station long-term ratings allocated proportionally to 158 L-prefix (suburban) FSAs. Per-FSA headroom =
(station_ltr_mw * 0.30) / n_fsas_served, bounded to [100 kW, 5,000 kW].
Simulates up to 300,000 individual EV agents using probability distributions derived from real City of Toronto data. Think of it like flipping a coin — flip it 10 times and you get roughly 5 heads. Flip it 300,000 times and you get the exact odds. That's Monte Carlo, but instead of coins, it's cars.
- Weighted random choice using zone-type probability weights
- Source: Toronto traffic intersection volumes (6,400 intersections mapped to 260 FSAs)
- Vectorized:
np.random.choice(fsa_list, size=num_evs, p=probabilities)
Mixture of normal distributions:
| Period | Distribution | Weight | Clip Range |
|---|---|---|---|
| Evening peak | N(17.5, 1.5) | 70% | 2 PM – 9 PM |
| Midday peak | N(12.0, 2.0) | 30% | 9 AM – 3 PM |
| Morning peak | N(8.25, 1.0) | 100% (morning mode) | 6 AM – 11:30 AM |
- Initialization: Beta(6, 2) distribution (mean 75% SoC)
- Charge deficiency: Gamma(3, 5) + 5 kWh base
- Charge decision: Sigmoid function:
sigmoid(soc_need + range_anxiety + proximity + dwell - detour) * availability
Physics-based battery degradation:
if temperature < 20°C:
efficiency_loss = min(0.40, (20 - temp) * 0.0085)
soc_needed = soc_needed / (1.0 - efficiency_loss)
At -15°C, vehicles need ~42% more energy, producing longer charging sessions and higher concurrent grid stress.
Grid stress depends on charging duration, not just energy needed:
duration_h = soc_needed_kwh / charger_kw(7 kW residential, 50 kW DCFC, 150 kW ultra-fast)- For each hour H: sum concurrent kW across all EVs where
arrival < H+1 AND departure > H - Peak hour per FSA:
argmax(hourly_load[fsa])
total_load = peak_ev_load + (ieso_baseline_fraction * capacity)
overloaded = total_load > feeder_capacity
deficit = max(0, total_load - feeder_capacity)
People aren't random — they make decisions. The Semi-Markov layer models state transitions between daily activities with conditional probabilities that shift based on real-world events.
Each person occupies one of 5 destination states:
| State | Icon | Population Share |
|---|---|---|
| Home | House | Base state |
| Work | Briefcase | 62% (workers) |
| School | Book | 8% (students) |
| Shopping/Retail | Cart | Variable |
| Leisure | Park | Variable |
Transitions between states are time-dependent and day-dependent (weekday vs. weekend). Example weekday transitions from Home:
Home → Work: 0.45
Home → School: 0.25
Home → Shopping: 0.15
Home → Home: 0.15 (stay home)
When external events occur, transition probabilities redistribute:
- School cancelled (snow day):
P(Home → School)drops to 0, its probability mass shifts toP(Home → Home). More people staying home reshapes charging demand across the entire city. - Work-from-home day:
P(Home → Work)reduces, increasing residential charging load. - Weekend: Work/school probabilities collapse, retail/leisure probabilities increase.
Unlike a standard Markov chain, dwell times at each state are not memoryless — they follow realistic duration distributions:
- Work dwell: ~8 hours (weekday)
- School dwell: ~6 hours
- Shopping dwell: ~1-2 hours
- Leisure dwell: ~2-3 hours
The time spent in each state directly affects charging opportunity, SoC evolution, and grid load timing.
Real road network grounding for the mobility simulation:
- Dual backend: OpenStreetMap via OSMnx (primary) + FSA-adjacency graph (offline fallback)
- Scale: 145,540 OSM nodes, 383,093 road edges, 1 connected component across 260 FSAs
- Speed profiles: 11 OSM highway classes (motorway: 95 kph → residential: 32 kph)
- Traffic multipliers: Hour-based congestion (AM peak +32%, PM peak +38%, overnight -8%)
- Routing accuracy: Median FSA centroid snap 90.8m, p95 892.8m. Network circuity median 1.317.
- Edge-flow aggregation: Each route path is walked over travel time, assigning traversals to the hour the vehicle reaches each segment (not departure hour)
Full agent-based weekly mobility simulation:
- Scale: 300,000 people in 324 seconds (927 people/sec) on arm64 macOS
- Output: 3.5M weekly trip legs, 184K charge events, 3.1M MWh grid load
- Population: StatCan 2021 Census (8.39M GTA residents), distributed across 260 FSAs
- Person types: Worker 62%, student 8%, retired 12%, other 18%
- EV sampling: Baseline 3% fleet penetration (StatCan 2.8% national, Ontario 8.1% new sales)
- Itineraries: 5 destination types with time/day-dependent attraction weights
- Trip legs: Weekly patterns (weekday vs. weekend), purpose-driven routing
- Battery tracking: SoC forward-propagated through ordered trips per agent per day
Real-world charger infrastructure:
- Public chargers: 2,889 NREL/AFDC Ontario stations (cached locally)
- Enrichment: OSM
amenity=charging_stationoverlay - Snap accuracy: AFDC-to-OSM p95 snap distance: 261m
| Zone Type | Charger Power | Public Density (per FSA) |
|---|---|---|
| Residential | 50 kW | 0.35 |
| Leisure | 90 kW | 1.25 |
| Office Park | 50 kW | 2.25 |
| Retail Hub | 50 kW | 3.50 |
| Transit Hub | 150 kW | 5.00 |
Private charging access probabilities: 70% home, 35% work, 30% nearby-home public, 25% work public, 45% retail public.
30+ fit targets with low/high/ideal ranges and weights:
| Category | Target | Ideal Value |
|---|---|---|
| Itinerary | Legs per person per week | 13.0 |
| Itinerary | Weekly km per person | 240.0 |
| Itinerary | Route p50 / p90 | 18 km / 55 km |
| Charging | Events per EV per week | 2.8 |
| Charging | Median detour penalty | 5 min |
| SoC | Final weekly drift | ±8% |
| SoC | Full-charge departure rate | 18% |
| Zones | Work/school purpose accuracy | 95% |
| Zones | Retail/leisure accuracy | 96% |
Candidate generation via grid search over public-charger access parameters (15-30% home, 22-30% work, 45-55% retail) and SoC targets.
PuLP mixed-integer linear programming solver for the Facility Location Problem.
Problem formulation:
- Sets: I = overloaded FSAs (demand), J = candidate station sites
- Variables:
y[j] ∈ {0,1}(open station?),x[i][j] ∈ [0,1](fraction served) - Objective: Maximize
Σ deficit[i] * coverage_weight[i][j] * x[i][j] - Coverage weight:
1 / (1 + haversine_km(i, j))— inverse distance decay - Budget:
Σ y[j] ≤ max_stations(user-configurable, 5-20) - Linking:
x[i][j] ≤ y[j]— can only serve from open stations - Demand cap:
Σ x[i][j] ≤ 1— each zone counted at most once - Fallback: Greedy top-N by deficit if solver doesn't find optimal
Asset prescription rules:
| Zone Type | Charger Type | Power | BESS Sizing |
|---|---|---|---|
| Residential | Level 2 Smart-Charging Hub | 7 kW/unit | deficit * 2h |
| Leisure | Level 2 Smart-Charging Hub | 7 kW/unit | deficit * 2h |
| Office Park | DC Fast Charging Array | 50 kW/unit | deficit * 2h |
| Retail Hub | DC Fast Charging Array | 50 kW/unit | deficit * 2h |
| Transit Hub | Ultra-Fast Charging Array | 150 kW/unit | deficit * 2h |
Unit calculation: charger_units = ceil(deficit_kw / kw_per_unit)
Two frontend implementations:
Interactive React dashboard powered by react-leaflet:
- Native Leaflet maps with smooth zoom/pan and dynamic region styling
- Three toggleable views: Energy Spikes (demand), Grid Vulnerability (overloads), Placements
- Interactive controls: EV count, temperature, time-of-day sliders
- Context-menu EV details: Right-click any FSA to see sample EV arrivals
- Custom Charger Editor: Manually add/remove chargers by FSA code
- Error resilience: Global ErrorBoundary, inline API error banners, loading overlays
Single-page Streamlit app with reactive controls:
- Sidebar: Adoption rate (10-50%), temperature (-20 to +30°C), time of day, station budget
- Caching:
@st.cache_datafor simulation results,@st.cache_resourcefor engine instance - Maps: Folium choropleth (yellow→red demand), binary green/red vulnerability, purple marker placement
- Prescription cards: HTML popups with charger type, unit count, total kW, BESS sizing
30+ validation gates tied to observed real-world data:
| Category | Validation | Source |
|---|---|---|
| Road graph | Snap distance, circuity, connectivity | OSM network metrics |
| Mobility | Legs/person, route km distributions | TTS origin-destination data |
| Charging | Charge frequency, SoC before/after | AFDC charger usage |
| Load profiles | Hourly energy, grid peaks, correlation | IESO baseline curves |
| Traffic | AM/PM FSA vehicle distributions | City of Toronto turning-movement counts (107 FSAs) |
| Reproducibility | Multi-seed consistency | Seeds 101, 202, 303 |
300K high-scale benchmark:
| Metric | Value |
|---|---|
| Population simulated | 300,000 people |
| Runtime | 324 seconds (927 ppl/sec) |
| Weekly trip legs | 3.5M |
| Charge events | 184K |
| Grid load | 3.1M MWh |
| Edge routes | 87.5B km across 161K corridors |
| EV edge traversals | 689K |
| Chargers mapped | 2,889 |
| Hourly grid timestamps | 43,680 |
seneca-hack/
├── backend/
│ ├── apis/
│ │ └── api.py # FastAPI entry point
│ ├── spatial_assembler.py # Phase 1 — GeoJSON + zone classification + capacity
│ ├── monte_carlo.py # Phase 2 — Monte Carlo simulation + grid test
│ ├── mobility_simulator.py # Semi-Markov agent-based weekly simulation
│ ├── road_network.py # OSM road graph + routing + edge-flow aggregation
│ ├── charger_catalog.py # AFDC/NREL charger database + zone mapping
│ ├── model_calibration.py # 30+ fit targets + grid search calibration
│ ├── simulation_validation.py # 30+ validation gates + benchmark runner
│ ├── observed_targets.py # Real-world validation targets (traffic, chargers, IESO)
│ ├── optimizer.py # Phase 3 — PuLP FLP solver + prescriptions
│ ├── map_builder.py # Phase 4 — Folium map visualization
│ ├── road_grid_dashboard.py # Road-grid Streamlit dashboard view
│ ├── scripts/
│ │ └── fetch_hydro_capacity.py # Toronto Hydro + Hydro One data ingestion
│ └── data/
│ ├── gta_fsa_boundaries.geojson # 260 FSA polygons (StatsCan 2021)
│ ├── fsa_zone_classification.csv # Zone type per FSA
│ ├── ieso_load_profile.csv # 24-hour baseline grid load curve
│ ├── zone_weights.json # Spatial probability weights (Toronto traffic)
│ ├── toronto_hydro_feeders.geojson # Real Toronto Hydro feeder capacity
│ ├── hydro_one_stations.csv # Suburban Hydro One station ratings
│ ├── fsa_population_scaling.csv # StatCan population per FSA
│ └── toronto_traffic_fsa_counts.csv # Toronto turning-movement traffic counts
├── frontend/
│ ├── public/
│ │ └── gta_fsa_boundaries.geojson
│ ├── src/
│ │ ├── api.js # Frontend API client
│ │ ├── App.jsx # Dashboard UI
│ │ ├── MapComponent.jsx # Leaflet native map
│ │ └── index.css # Theme styles
│ ├── package.json
│ └── vite.config.js
├── presentation/
│ ├── index.html # Reveal.js 12-slide presentation
│ ├── capture_screenshots.py # Automated Folium → Chrome screenshot pipeline
│ └── screenshots/ # Dashboard screenshots for presentation
├── data_preparation/
│ ├── prepare_data.py # StatsCan boundaries + ArcGIS classification
│ ├── fetch_toronto_traffic.py # Toronto Open Data traffic volumes
│ ├── fetch_statcan_fsa_population.py # StatCan population data
│ ├── fetch_real_world_grid.py # Real-world grid data fetcher
│ ├── run_model_validation.py # Full validation suite runner
│ └── benchmark_simulation_scale.py # 300K high-scale benchmark
├── tests/
│ ├── test_spatial_assembler.py
│ ├── test_monte_carlo.py
│ ├── test_optimizer.py
│ ├── test_mobility_simulator.py # 1,336 lines of mobility tests
│ ├── test_road_grid_mapping.py
│ ├── test_road_grid_dashboard.py
│ ├── test_run_model_validation.py
│ ├── test_benchmark_simulation_scale.py
│ └── test_model_assumption_coverage.py
├── docs/
│ ├── architecture.md
│ ├── model_assumptions.md # All constants, sources, and rationale
│ ├── implementation_plan.md
│ └── benchmarks/
│ └── high_scale_benchmark_300000.json
├── app.py # Streamlit dashboard entry point
└── pyproject.toml # Python 3.14 dependencies
| Source | Data | Usage |
|---|---|---|
| Statistics Canada | 2021 Census FSA boundaries + population (8.39M) | 260 GTA postal zones, population scaling |
| Toronto Hydro | ArcGIS feeder capacity data | Real grid capacity for 102 Toronto FSAs |
| Hydro One | Station long-term MW ratings | Suburban grid capacity for 158 FSAs |
| IESO | Ontario hourly load profile | 24-hour baseline grid stress curve |
| City of Toronto | Traffic intersection volumes (6,400 intersections) | Spatial probability weights |
| City of Toronto | Turning-movement counts (107 FSAs) | Validation targets |
| NREL/AFDC | 2,889 Ontario public EV charger stations | Charger catalog + spatial mapping |
| OpenStreetMap | Road network (145K nodes, 383K edges) | Routing, travel times, edge-flow aggregation |
uv run pytest tests/ -v97 tests covering spatial data integrity, simulation correctness, grid inequality logic, optimizer constraints, mobility patterns, road network metrics, charging behavior, validation gates, and benchmark reproducibility.
Python 3.14 | FastAPI | Uvicorn | React (Vite) | React Leaflet | Streamlit | Folium | GeoPandas | Shapely | NumPy | Pandas | SciPy | PuLP | OSMnx | NetworkX
Seneca Hackathon 2026