Team: 102.3 · Vayu AI Studio Hackathon · Track: Move-It
🔗 Live demo — deployed on Vayu ML Service, both modes running with live simulated telemetry.
Problem: Battery-swap networks rotate packs by guesswork because no one fuses per-pack health data across a fleet, and fleet charging depots discover their shared transformer is overloaded only after it trips — tripped breakers and blacked-out blocks are a documented, recurring failure, not a hypothetical. In both cases the sensor telemetry needed to prevent the problem already exists; it's just never fused with a site-level forecast and turned into an automated action.
Outcome: One Vayu-native pipeline, two configurable modes, sharing the same telemetry schema, the same two-model structure (a per-unit health regressor + a site-level forecaster), and the same closed loop: simulated sensors → Kafka → FastAPI ingestion → model inference → an automated action published back over Kafka → the simulator visibly reacting — with a live Streamlit dashboard judges can drive themselves, including manual override, a kill switch, and a one-click test trigger to force the loop end to end.
Rakesh runs EV infrastructure on the outskirts of Pune — a 40-charger fleet depot overnight, and a small network of battery-swap stations for the delivery riders who pass through by day. Both run into the same wall: he can see the numbers, but only after something's already gone wrong. A batch of vans plugs in at once and the shared 500 kVA transformer trips before he gets a warning — the same failure mode that blacked out a residential block near a real Pune EV hub. A pack keeps getting swapped to riders until one of them complains their range dropped, and a station runs a queue during peak hours while another sits on inventory it doesn't need. He's managing all of it on manual caps and gut-feel rotation, because nothing fuses the sensor data he already has into a decision he can act on in time.
PowerIQ closes that gap with one engine and a mode switch. On the depot side, it forecasts site load 15 minutes ahead and auto-throttles the most recently connected stall the moment the forecast crosses 85% of the transformer limit — before a trip, not after. On the swap side, it scores every pack's health on ingestion, auto-retires anything below a safe threshold, and forecasts swap demand per station so it can recommend moving packs before a queue forms. Same telemetry shape, same two-model structure, same automated-action pattern — just "stall" vs. "pack" and a different action taken.
Before: Rakesh finds out from a tripped breaker or a frustrated rider. After: a live gauge and forecast tell him what's about to happen, and the system acts on it automatically, with a manual override and kill switch next to it if he wants to step in.
(Full version with sourcing/market data: 09_docs/user_story.md, the
standalone 1-page deliverable.)
| File | What it is |
|---|---|
submission/team5_102.3.pdf |
Solution document — problem, approach, platform services used, results |
submission/team5_102.3.mp4 |
Demo video — both modes closing the loop live, judge-triggered |
| Doc | Covers |
|---|---|
| This README | Problem, architecture, quick start, deploy steps, model results |
USAGE.md |
Local setup, running both APIs + dashboard, known fixes |
09_docs/API_REFERENCE.md |
Every endpoint on both ingestion APIs — request/response shapes |
09_docs/ENVIRONMENT_VARIABLES.md |
Every config var, its default, and what breaks if it's wrong |
09_docs/DEPLOYMENT_RUNBOOK.md |
Master deploy runbook — depot + swap, unified, one linear pass from “wire it to Vayu” to “judge can open a URL” |
09_docs/user_story.md |
Standalone 1-page user story for both modes |
01_dataset/README.md / README_swap.md |
Dataset cards — source, generation method, grounding assumptions |
00_vayu_workspaces/ → 08_deploy/ |
One README per numbered pipeline stage, each scoped to that stage only |
boo.md |
Full original proposal (market sizing, competitive analysis, roadmap) |
| Deliverable | Where it lives | Status |
|---|---|---|
| Historical telemetry in Object Storage | 01_dataset/ (depot + swap CSVs) |
Uploaded |
| Both models trained, registered, MLflow-logged | 05_model_registry/register_*.py |
Code ready — run against your MLFLOW_TRACKING_URI |
Kafka telemetry-in + command-out live |
03_vayu_kafka/ |
Code ready — confirm with test_connection.py |
| Model Serving endpoints (cloud inference) | — | deployed |
| Dashboard live at a public URL | 07_build_app/app_unified.py |
deployed |
| Docker images built, signed, pushed | 07_build_app/Dockerfile.* |
done |
| Simulator publishes + reacts over Kafka | simulator.py, simulator_swap.py |
Built, unit-verified |
| Judge can trigger the loop from the dashboard | sidebar "Judge Testing" panel | Built |
| Observability panel (telemetry rate, command success) | sidebar "Live Metrics" panel | Built |
| Safety guard (kill switch, concurrency cap) | both ingestion APIs | Built |
| User story | 09_docs/user_story.md |
Written |
DEPOT MODE
simulator.py (fake ESP32, per-stall telemetry)
│ POST /ingest every 1-5s
▼
ingestion_api.py ──publish_reading──▶ Kafka topic: telemetry-in
│ forecast site load 15min ahead
│ (Vayu Model Serving, falls back to local forecast_model.joblib)
▼
overload_risk? ──yes──▶ _apply_throttle() ──publish_command──▶ Kafka topic: command-out
│ │
│ /forecast /state /alerts /metrics (polled) │ consumed by
▼ ▼
app_unified.py (Streamlit) simulator.py kafka_command_loop()
live gauge, per-stall bars, kill switch, trickle-charges the throttled
manual Throttle/Release override stall instead of full draw
(device visibly reacts)
SWAP-STATION MODE — identical shape, "pack" instead of "stall"
simulator_swap.py ──▶ ingestion_api_swap.py ──▶ Kafka telemetry-in
│ SoH score (health model) + per-station demand forecast
▼
low SoH? ──yes──▶ _apply_retire() ──publish_command──▶ Kafka command-out
│ │
▼ ▼
app_unified.py (Swap Station mode) simulator_swap.py kafka_command_loop()
station heat-bands, SoH histogram, pulls the pack from circulation
redistribution recs, manual Retire/Un-retire (device visibly reacts)
Both modes share one Kafka client (03_vayu_kafka/kafka_client.py), one
telemetry schema, and one closed-loop pattern: an automated decision
(_apply_throttle / _apply_retire) is the single code path used by both
the automatic threshold-crossing trigger and the dashboard's manual
override button, and always publishes to command-out when
KAFKA_ENABLED=true.
poweriq/
├── 00_vayu_workspaces/README.md
├── 01_dataset/
│ ├── depot_telemetry.csv # depot training data
│ ├── pack_health.csv, swap_demand.csv # swap-mode training data
│ └── README.md, README_swap.md # dataset cards
├── 02_vayu_mlflow/README.md
├── 03_vayu_kafka/
│ ├── kafka_client.py # producer/consumer, shared by both modes
│ ├── create_topic.py # provisions telemetry-in + command-out
│ └── test_connection.py # verifies Kafka auth on both topics
├── 04_starter-kit/
│ ├── train_forecast_model.py # depot site-load forecaster
│ ├── train_health_model.py # swap pack SoH regressor
│ └── train_swap_forecast_model.py # swap demand forecaster
├── 05_model_registry/ # trained models + metadata + register_*.py
├── 07_build_app/
│ ├── config.py # env-var config for both modes
│ ├── simulator.py, simulator_swap.py
│ ├── ingestion_api.py, ingestion_api_swap.py
│ ├── app.py # depot-only dashboard (fallback)
│ ├── app_unified.py # depot + swap, mode toggle — deploy this
│ ├── Dockerfile.ingestion, Dockerfile.ingestion-swap
│ ├── Dockerfile.dashboard, Dockerfile.dashboard-unified
│ └── requirements-ingest.txt, requirements-dashboard.txt
├── 08_deploy/image-signing/sign_image.py
├── 09_docs/user_story.md
├── requirements.txt
├── .env.example
└── boo.md # full proposal
pip install -r requirements.txt
# depot mode
cd 07_build_app
python ingestion_api.py # terminal 1 — http://localhost:8000
python simulator.py --stalls 20 --interval 3 # terminal 2
streamlit run app_unified.py # terminal 3 — http://localhost:8501
# swap mode (run alongside depot mode, or on its own)
python ingestion_api_swap.py # terminal 4 — http://localhost:8001
python simulator_swap.py --packs-per-station 20 --interval 3 # terminal 5Force a depot overload to see the alert path fire:
python -c "
import requests
for i in range(40):
requests.post('http://localhost:8000/ingest', json={
'unit_id': f'stall-{i:03d}', 'unit_type': 'stall',
'timestamp': '2026-07-11T09:00:00+00:00',
'voltage': 230.0, 'current': 90.0,
'temperature': 35.0, 'cycle_count': 10, 'station_ambient_temp': 35.0,
})
print(requests.get('http://localhost:8000/forecast').json())
"Or use the dashboard sidebar's Judge Testing panel — it calls the same
/trigger_test endpoint in both modes without needing a terminal.
- Copy
.env.exampleto.envand fill in the Kafka/S3/Postgres passwords from your Access Guide. SetKAFKA_ENABLED=trueonce running inside the Vayu Workspace — the Kafka broker is only reachable from there. - Run
python 03_vayu_kafka/test_connection.pyfrom inside the Workspace to confirm bothtelemetry-inandcommand-outtopics are reachable. If you only have one provisioned topic, setKAFKA_COMMAND_TOPIC=<your topic>to multiplex both directions over it (messages carry amessage_typefield so they stay distinguishable). - Upload
01_dataset/depot_telemetry.csv(and the swap CSVs) to Object Storage via S3 Browser. - Log training runs to MLflow and register both models:
export MLFLOW_TRACKING_URI=<your Vayu MLflow URL> python 05_model_registry/register_model.py # depot — logs 3 runs python 05_model_registry/register_health_model.py # swap SoH python 05_model_registry/register_swap_forecast_model.py # swap demand
- Deploy each registered model via Model Serving, then set
PREDICT_URL/HEALTH_PREDICT_URL/SWAP_PREDICT_URLin.env(or as ML Service env vars) so forecasts come from the cloud endpoint instead of the local.joblibfallback. - Build, sign, and push the containers:
Vayu ML Services reject unsigned images — don't skip the sign step.
docker login image-registry-vks-ir-chn2vcr1.cloudservices.tatacommunications.com # do this BEFORE building --push docker build -t $IMAGE_REGISTRY/$REGISTRY_PROJECT/poweriq-ingest:latest . -f 07_build_app/Dockerfile.ingestion --push docker build -t $IMAGE_REGISTRY/$REGISTRY_PROJECT/poweriq-ingest-swap:latest . -f 07_build_app/Dockerfile.ingestion-swap --push docker build -t $IMAGE_REGISTRY/$REGISTRY_PROJECT/poweriq-dashboard:latest . -f 07_build_app/Dockerfile.dashboard-unified --push set -a && source .env && set +a export IMAGE=$IMAGE_REGISTRY/$REGISTRY_PROJECT/poweriq-ingest:latest && python 08_deploy/image-signing/sign_image.py sign export IMAGE=$IMAGE_REGISTRY/$REGISTRY_PROJECT/poweriq-ingest-swap:latest && python 08_deploy/image-signing/sign_image.py sign export IMAGE=$IMAGE_REGISTRY/$REGISTRY_PROJECT/poweriq-dashboard:latest && python 08_deploy/image-signing/sign_image.py sign
- Realtime Inference → ML Service → Create Deployment for all three
images. Set
INGEST_API_BASE/SWAP_INGEST_API_BASEon the dashboard deployment to the other two deployments' URLs. Open port 443 on every endpoint's public IP (firewall rule SOP) so judges can reach them.
Numbers below are read straight from the metadata committed alongside each
model in 05_model_registry/*_metadata.json — retrain and these files (and
this section) should be regenerated together so they never drift apart.
- Depot forecast: RandomForestRegressor, 300 trees, depth 12. Test MAE
37.1 kW, R² 0.78, overload-risk accuracy 97.7%, recall 42% on the rare
overload class — the dataset's overload events look like sudden demand
spikes, not perfectly predicted from rolling averages alone. Mitigation
already built in:
ingestion_api.pyalso flags risk the instant current load crosses the 85% margin, so a live overload is never missed just because the forecast under-predicted it. - Swap SoH model: R² 0.91, MAE 3.1 pts, retirement-flag accuracy 94.6%, retirement recall 0.83.
- Swap demand model: R² 0.78, MAE 1.7 swaps/15min, shortfall-risk accuracy 95.7%, recall 0.42 — same honest limitation as depot: rare positive class, harder to catch than the common case.
Retrain: python 04_starter-kit/train_forecast_model.py (depot),
train_health_model.py / train_swap_forecast_model.py (swap).
Both app.py and app_unified.py used to end the script with
time.sleep(DASHBOARD_REFRESH_SECONDS); st.rerun() at the top level. A
bare st.rerun() tears down and rebuilds the entire Streamlit page —
sidebar, charts, everything — and this was firing on a fixed timer, which
is what produced the white-flash-every-few-seconds effect. It also meant
every button click competed with that timer: a click triggers its own
full-page rerun on top of whatever the auto-refresh was already doing, and
each render pass made 5–6 sequential blocking HTTP calls to the ingestion
API before it could draw anything, so clicks could feel like they'd done
nothing for several seconds.
Fixed by:
- Wrapping the live panel in
@st.fragment(run_every=DASHBOARD_REFRESH_SECONDS), so the timer only re-runs that fragment, not the whole app — no more flash, and widgets outside it (like the mode toggle) stay responsive while it refreshes. - Firing all of a render pass's API calls concurrently via a small
ThreadPoolExecutor(fetch_many) instead of one after another. - Switching every button/toggle's
st.rerun()tost.rerun(scope="fragment"), so an action redraws only the fragment it's in, not the whole page.
- In-memory aggregation, not a Kafka-consuming aggregator service — both ingestion APIs aggregate in-process for demo reliability, and publish to Kafka in parallel so the topic is populated for the platform-usage deliverable. A dedicated Kafka-consumer aggregator is the "more correct" architecture, called out here as a known simplification rather than an oversight.
- Redistribution/throttling rules are threshold heuristics, not a full
routing optimizer — intentional for the hackathon timeline, documented as
the clearest near-term roadmap item in
boo.md.