Upload 18 files
Browse files- src/README.md +158 -0
- src/SIGNED.md +16 -0
- src/app.py +461 -0
- src/appliances.json +82 -0
- src/businesses.json +74 -0
- src/digest_spec.md +127 -0
- src/eda_plots.png +0 -0
- src/eval.ipynb +252 -0
- src/feature_importance.png +0 -0
- src/forecast_plot.png +0 -0
- src/forecaster.py +304 -0
- src/generate_data.py +159 -0
- src/grid_history.csv +0 -0
- src/lite_ui.html +508 -0
- src/lite_v2_ui.html +516 -0
- src/prioritizer.py +269 -0
- src/process_log.md +56 -0
- src/requirements.txt +3 -0
src/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# T2.3 Β· Grid Outage Forecaster + Appliance Prioritizer
|
| 2 |
+
**AIMS KTT Fellowship Hackathon 2026**
|
| 3 |
+
|
| 4 |
+
Predict 24-hour grid outage probability and generate actionable load-shedding plans for SMEs β designed for low-bandwidth, offline-first, non-smartphone users in Rwanda.
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## β‘ Quickstart (β€ 2 commands, free Colab CPU)
|
| 9 |
+
|
| 10 |
+
```bash
|
| 11 |
+
pip install pandas numpy scikit-learn lightgbm
|
| 12 |
+
python generate_data.py && python prioritizer.py salon
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
That's it. Generates all data, fits the model, prints the 24h plan and SMS digest for the salon archetype.
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## π Evaluation Metrics (30-day held-out)
|
| 20 |
+
|
| 21 |
+
| Metric | Value | Baseline |
|
| 22 |
+
|--------|-------|----------|
|
| 23 |
+
| Brier Score (P outage) | **0.1756** | 0.212 (naΓ―ve rate) |
|
| 24 |
+
| Duration MAE | **61.2 min** | β |
|
| 25 |
+
| Avg Lead Time | **2.79 h** | β |
|
| 26 |
+
| Inference Latency | **< 300 ms CPU** | β |
|
| 27 |
+
| Retrain Time | **< 5 min** | β |
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## π Repository Structure
|
| 32 |
+
|
| 33 |
+
```
|
| 34 |
+
βββ generate_data.py # Synthetic data generator (reproducible, seed=42)
|
| 35 |
+
βββ forecaster.py # LightGBM probabilistic outage forecaster
|
| 36 |
+
βββ prioritizer.py # Constrained appliance load-shedding planner
|
| 37 |
+
βββ lite_ui.html # Static 50KB dashboard (open in any browser)
|
| 38 |
+
βββ digest_spec.md # Product & Business adaptation artifact
|
| 39 |
+
βββ process_log.md # Hour-by-hour timeline + LLM tool use
|
| 40 |
+
βββ SIGNED.md # Honor code (signed)
|
| 41 |
+
βββ eval.ipynb # Rolling evaluation notebook
|
| 42 |
+
βββ grid_history.csv # Generated: 180 days Γ hourly grid data
|
| 43 |
+
βββ appliances.json # 10 appliances with categories + revenue
|
| 44 |
+
βββ businesses.json # 3 business archetypes (salon, cold room, tailor)
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## π§ Usage
|
| 50 |
+
|
| 51 |
+
### Generate data
|
| 52 |
+
```bash
|
| 53 |
+
python generate_data.py
|
| 54 |
+
# β grid_history.csv, appliances.json, businesses.json
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
### Run forecast (CLI)
|
| 58 |
+
```bash
|
| 59 |
+
python forecaster.py # 24h forecast preview
|
| 60 |
+
python forecaster.py --eval # Rolling 30-day Brier + MAE
|
| 61 |
+
python forecaster.py --serve # JSON output + latency
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
### Run appliance plan
|
| 65 |
+
```bash
|
| 66 |
+
python prioritizer.py salon # Salon archetype
|
| 67 |
+
python prioritizer.py cold_room # Cold room archetype
|
| 68 |
+
python prioritizer.py tailor # Tailor archetype
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
### Open UI
|
| 72 |
+
```bash
|
| 73 |
+
# Just open lite_ui.html in any browser β no server needed
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## ποΈ Architecture
|
| 79 |
+
|
| 80 |
+
```
|
| 81 |
+
grid_history.csv
|
| 82 |
+
β
|
| 83 |
+
βΌ
|
| 84 |
+
forecaster.py::build_features() β lag features, rolling stats, weather, temporal
|
| 85 |
+
β
|
| 86 |
+
βΌ
|
| 87 |
+
LightGBM Classifier β P(outage) per hour
|
| 88 |
+
LightGBM Regressor β E[duration | outage] per hour
|
| 89 |
+
β
|
| 90 |
+
βΌ
|
| 91 |
+
prioritizer.py::plan()
|
| 92 |
+
Shed order: luxury β comfort β critical
|
| 93 |
+
Tie-break: lowest revenue-per-hour shed first
|
| 94 |
+
Exception: critical protected during peak hours
|
| 95 |
+
β
|
| 96 |
+
βΌ
|
| 97 |
+
lite_ui.html (forecast chart + appliance grid + SMS digest)
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
---
|
| 101 |
+
|
| 102 |
+
## π Product & Business Design
|
| 103 |
+
|
| 104 |
+
Designed for **low-bandwidth, offline-first, non-smartphone users**:
|
| 105 |
+
|
| 106 |
+
- **Feature phone SMS digest** (3 Γ 160 chars) at 06:30 CAT β no internet required for the end user
|
| 107 |
+
- **Offline fallback**: cached plan valid 6h, staleness banner after that, plan expired after 8h
|
| 108 |
+
- **Illiteracy adaptation**: Colored LED relay board (ESP32 + 3-channel relay, ~USD 8/unit) β red/green/yellow per appliance slot, no reading required
|
| 109 |
+
- **Cost**: ~RWF 30/business/day all-in (SMS + server amortized across 200+ subscribers)
|
| 110 |
+
- **Revenue protected**: ~RWF 62,000/week per salon vs naΓ―ve full-on operation
|
| 111 |
+
|
| 112 |
+
See `digest_spec.md` for full specification with numbers, users, and workflows.
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
## πΉ 4-Minute Video
|
| 117 |
+
|
| 118 |
+
[YouTube link β to be inserted before submission]
|
| 119 |
+
|
| 120 |
+
**Video structure:**
|
| 121 |
+
- 0:00β0:30 On-camera intro: name, challenge ID, Brier score 0.1756
|
| 122 |
+
- 0:30β1:30 Live code: `prioritizer.py::plan()` β critical-before-luxury logic
|
| 123 |
+
- 1:30β2:30 Live demo: `lite_ui.html` salon forecast + plan
|
| 124 |
+
- 2:30β3:30 Read `digest_spec.md` morning SMS aloud
|
| 125 |
+
- 3:30β4:00 Three spoken answers
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
## π€ Model Hosting
|
| 130 |
+
|
| 131 |
+
Model weights (LightGBM pkl files) hosted on Hugging Face Hub:
|
| 132 |
+
`[HF link β to be inserted before submission]`
|
| 133 |
+
|
| 134 |
+
Alternatively, retrain from scratch in < 5 min:
|
| 135 |
+
```bash
|
| 136 |
+
python forecaster.py --fit
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## π License
|
| 142 |
+
|
| 143 |
+
MIT License β see LICENSE file.
|
| 144 |
+
|
| 145 |
+
---
|
| 146 |
+
|
| 147 |
+
## β
Submission Checklist
|
| 148 |
+
|
| 149 |
+
- [x] Public GitHub repo with README
|
| 150 |
+
- [x] `generate_data.py` β reproducible in 2 commands
|
| 151 |
+
- [x] `forecaster.py` + `prioritizer.py`
|
| 152 |
+
- [x] `lite_ui.html` β < 50KB static page
|
| 153 |
+
- [x] `eval.ipynb` β rolling 30-day metrics
|
| 154 |
+
- [x] `digest_spec.md` β Product & Business artifact with real numbers
|
| 155 |
+
- [x] `process_log.md` β timeline + LLM use declared
|
| 156 |
+
- [x] `SIGNED.md` β honor code signed
|
| 157 |
+
- [ ] 4-minute video URL (to be added)
|
| 158 |
+
- [ ] Hugging Face model card link (to be added)
|
src/SIGNED.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SIGNED.md Β· Honor Code
|
| 2 |
+
|
| 3 |
+
**Name:** Nathnael Dereje Mengistu
|
| 4 |
+
**Date:** 2026-04-23
|
| 5 |
+
**Challenge:** T2.3 Β· Grid Outage Forecaster + Appliance Prioritizer
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Honor Code
|
| 10 |
+
|
| 11 |
+
"I will use any LLM or coding-assistant tool I find useful, and I will declare each tool I use, why I used it, and three sample prompts in my process_log.md. I will not have another human do my work. I will defend my own code in the Live Defense session. I understand undeclared LLM or human assistance is grounds for disqualification."
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
*Signed: Nathnael Dereje Mengistu*
|
| 16 |
+
*2026-04-23*
|
src/app.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import plotly.graph_objects as go
|
| 3 |
+
import pandas as pd
|
| 4 |
+
|
| 5 |
+
# ββ Page config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
+
st.set_page_config(
|
| 7 |
+
page_title="T2.3 Β· Grid Outage Forecaster",
|
| 8 |
+
page_icon="β‘",
|
| 9 |
+
layout="wide",
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
# ββ Custom CSS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
+
st.markdown("""
|
| 14 |
+
<style>
|
| 15 |
+
[data-testid="stAppViewContainer"] { background: #0f1117; color: #e8eaf6; }
|
| 16 |
+
[data-testid="stSidebar"] { background: #1a1d27; }
|
| 17 |
+
.metric-card {
|
| 18 |
+
background: #1a1d27; border: 1px solid #2e3350; border-radius: 10px;
|
| 19 |
+
padding: 14px 18px; text-align: center;
|
| 20 |
+
}
|
| 21 |
+
.metric-val { font-size: 1.6rem; font-weight: 800; color: #6366f1; }
|
| 22 |
+
.metric-lbl { font-size: 11px; color: #8892b0; text-transform: uppercase; letter-spacing: .05em; }
|
| 23 |
+
.badge {
|
| 24 |
+
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
| 25 |
+
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
|
| 26 |
+
}
|
| 27 |
+
.badge-high { background: #7f1d1d; color: #fca5a5; }
|
| 28 |
+
.badge-medium { background: #78350f; color: #fcd34d; }
|
| 29 |
+
.badge-low { background: #14532d; color: #86efac; }
|
| 30 |
+
.badge-on { background: #14532d; color: #86efac; }
|
| 31 |
+
.badge-off { background: #3f3f46; color: #a1a1aa; }
|
| 32 |
+
.badge-critical{ background: #1e3a8a; color: #93c5fd; }
|
| 33 |
+
.badge-comfort { background: #4a1d96; color: #c4b5fd; }
|
| 34 |
+
.badge-luxury { background: #374151; color: #9ca3af; }
|
| 35 |
+
.ap-card {
|
| 36 |
+
background: #1a1d27; border: 1px solid #2e3350; border-radius: 8px;
|
| 37 |
+
padding: 12px 14px; margin-bottom: 8px;
|
| 38 |
+
}
|
| 39 |
+
.ap-card.off { opacity: .6; border-color: #3f3f46; }
|
| 40 |
+
.ap-name { font-weight: 600; font-size: 14px; color: #e8eaf6; margin-bottom: 4px; }
|
| 41 |
+
.ap-meta { display: flex; gap: 6px; margin-bottom: 4px; }
|
| 42 |
+
.ap-shed { font-size: 10px; color: #9ca3af; margin-top: 3px; }
|
| 43 |
+
.ap-right { text-align: right; font-size: 12px; color: #8892b0; }
|
| 44 |
+
.ap-rev { color: #22c55e; font-weight: 600; font-size: 13px; }
|
| 45 |
+
.sms-box {
|
| 46 |
+
background: #22263a; border: 1px solid #2e3350; border-radius: 8px;
|
| 47 |
+
padding: 14px; margin-bottom: 10px; font-family: monospace; font-size: 13px;
|
| 48 |
+
line-height: 1.6; color: #e8eaf6;
|
| 49 |
+
}
|
| 50 |
+
.plan-header {
|
| 51 |
+
background: #1a1d27; border: 1px solid #2e3350; border-radius: 8px;
|
| 52 |
+
padding: 12px 16px; margin-bottom: 12px;
|
| 53 |
+
}
|
| 54 |
+
.section-title { font-size: 1rem; font-weight: 600; color: #e8eaf6; margin-bottom: 10px; }
|
| 55 |
+
h1, h2, h3 { color: #e8eaf6 !important; }
|
| 56 |
+
.stSelectbox label, .stSlider label { color: #8892b0 !important; }
|
| 57 |
+
div[data-testid="metric-container"] {
|
| 58 |
+
background: #1a1d27; border: 1px solid #2e3350; border-radius: 8px; padding: 8px;
|
| 59 |
+
}
|
| 60 |
+
</style>
|
| 61 |
+
""", unsafe_allow_html=True)
|
| 62 |
+
|
| 63 |
+
# ββ Embedded Data βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
FORECAST = [
|
| 65 |
+
{"hour_offset":0,"timestamp":"2024-06-29 00:00","hour":0,"p_outage":0.2708,"p_outage_low":0.1908,"p_outage_high":0.3508,"expected_duration_min":89.8,"risk_level":"HIGH"},
|
| 66 |
+
{"hour_offset":1,"timestamp":"2024-06-29 01:00","hour":1,"p_outage":0.2554,"p_outage_low":0.1754,"p_outage_high":0.3354,"expected_duration_min":83.2,"risk_level":"HIGH"},
|
| 67 |
+
{"hour_offset":2,"timestamp":"2024-06-29 02:00","hour":2,"p_outage":0.2169,"p_outage_low":0.1369,"p_outage_high":0.2969,"expected_duration_min":85.0,"risk_level":"MEDIUM"},
|
| 68 |
+
{"hour_offset":3,"timestamp":"2024-06-29 03:00","hour":3,"p_outage":0.2554,"p_outage_low":0.1754,"p_outage_high":0.3354,"expected_duration_min":85.0,"risk_level":"HIGH"},
|
| 69 |
+
{"hour_offset":4,"timestamp":"2024-06-29 04:00","hour":4,"p_outage":0.2602,"p_outage_low":0.1802,"p_outage_high":0.3402,"expected_duration_min":78.8,"risk_level":"HIGH"},
|
| 70 |
+
{"hour_offset":5,"timestamp":"2024-06-29 05:00","hour":5,"p_outage":0.2503,"p_outage_low":0.1703,"p_outage_high":0.3303,"expected_duration_min":85.0,"risk_level":"HIGH"},
|
| 71 |
+
{"hour_offset":6,"timestamp":"2024-06-29 06:00","hour":6,"p_outage":0.24, "p_outage_low":0.16, "p_outage_high":0.32, "expected_duration_min":83.2,"risk_level":"MEDIUM"},
|
| 72 |
+
{"hour_offset":7,"timestamp":"2024-06-29 07:00","hour":7,"p_outage":0.2208,"p_outage_low":0.1408,"p_outage_high":0.3008,"expected_duration_min":78.5,"risk_level":"MEDIUM"},
|
| 73 |
+
{"hour_offset":8,"timestamp":"2024-06-29 08:00","hour":8,"p_outage":0.2208,"p_outage_low":0.1408,"p_outage_high":0.3008,"expected_duration_min":78.5,"risk_level":"MEDIUM"},
|
| 74 |
+
{"hour_offset":9,"timestamp":"2024-06-29 09:00","hour":9,"p_outage":0.198, "p_outage_low":0.118, "p_outage_high":0.278, "expected_duration_min":86.0,"risk_level":"MEDIUM"},
|
| 75 |
+
{"hour_offset":10,"timestamp":"2024-06-29 10:00","hour":10,"p_outage":0.24, "p_outage_low":0.16, "p_outage_high":0.32, "expected_duration_min":71.3,"risk_level":"MEDIUM"},
|
| 76 |
+
{"hour_offset":11,"timestamp":"2024-06-29 11:00","hour":11,"p_outage":0.2531,"p_outage_low":0.1731,"p_outage_high":0.3331,"expected_duration_min":73.1,"risk_level":"HIGH"},
|
| 77 |
+
{"hour_offset":12,"timestamp":"2024-06-29 12:00","hour":12,"p_outage":0.2457,"p_outage_low":0.1657,"p_outage_high":0.3257,"expected_duration_min":76.9,"risk_level":"MEDIUM"},
|
| 78 |
+
{"hour_offset":13,"timestamp":"2024-06-29 13:00","hour":13,"p_outage":0.263, "p_outage_low":0.183, "p_outage_high":0.343, "expected_duration_min":68.8,"risk_level":"HIGH"},
|
| 79 |
+
{"hour_offset":14,"timestamp":"2024-06-29 14:00","hour":14,"p_outage":0.2582,"p_outage_low":0.1782,"p_outage_high":0.3382,"expected_duration_min":72.5,"risk_level":"HIGH"},
|
| 80 |
+
{"hour_offset":15,"timestamp":"2024-06-29 15:00","hour":15,"p_outage":0.2194,"p_outage_low":0.1394,"p_outage_high":0.2994,"expected_duration_min":76.9,"risk_level":"MEDIUM"},
|
| 81 |
+
{"hour_offset":16,"timestamp":"2024-06-29 16:00","hour":16,"p_outage":0.2688,"p_outage_low":0.1888,"p_outage_high":0.3488,"expected_duration_min":83.4,"risk_level":"HIGH"},
|
| 82 |
+
{"hour_offset":17,"timestamp":"2024-06-29 17:00","hour":17,"p_outage":0.309, "p_outage_low":0.229, "p_outage_high":0.389, "expected_duration_min":84.6,"risk_level":"HIGH"},
|
| 83 |
+
{"hour_offset":18,"timestamp":"2024-06-29 18:00","hour":18,"p_outage":0.3353,"p_outage_low":0.2553,"p_outage_high":0.4153,"expected_duration_min":84.6,"risk_level":"HIGH"},
|
| 84 |
+
{"hour_offset":19,"timestamp":"2024-06-29 19:00","hour":19,"p_outage":0.3408,"p_outage_low":0.2608,"p_outage_high":0.4208,"expected_duration_min":76.1,"risk_level":"HIGH"},
|
| 85 |
+
{"hour_offset":20,"timestamp":"2024-06-29 20:00","hour":20,"p_outage":0.3353,"p_outage_low":0.2553,"p_outage_high":0.4153,"expected_duration_min":99.4,"risk_level":"HIGH"},
|
| 86 |
+
{"hour_offset":21,"timestamp":"2024-06-29 21:00","hour":21,"p_outage":0.3466,"p_outage_low":0.2666,"p_outage_high":0.4266,"expected_duration_min":100.6,"risk_level":"HIGH"},
|
| 87 |
+
{"hour_offset":22,"timestamp":"2024-06-29 22:00","hour":22,"p_outage":0.2834,"p_outage_low":0.2034,"p_outage_high":0.3634,"expected_duration_min":102.5,"risk_level":"HIGH"},
|
| 88 |
+
{"hour_offset":23,"timestamp":"2024-06-29 23:00","hour":23,"p_outage":0.2596,"p_outage_low":0.1796,"p_outage_high":0.3396,"expected_duration_min":106.9,"risk_level":"HIGH"},
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
SMS = [
|
| 92 |
+
"UMURIRO FORECAST 24H: Risk=HIGH at 0h,1h,3h. Shed: Standing+TV. Est.save: 12,418RWF. Stay alert!",
|
| 93 |
+
"PLAN: Turn OFF Standing+TV during risk hrs (0h,1h,3h). Keep dryer+clippers+lights ON. Generator ready?",
|
| 94 |
+
"If no signal by 13h, use YESTERDAY plan. Risk valid 6h. Call 0788-GRID for live update. Good business!",
|
| 95 |
+
]
|
| 96 |
+
|
| 97 |
+
# ββ Appliance plan generators βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
def salon_appliances(hour, risk):
|
| 99 |
+
open_ = 7 <= hour <= 20
|
| 100 |
+
peak = 9 <= hour <= 17
|
| 101 |
+
scale = 1.0 if peak else (0.75 if open_ else 0.0)
|
| 102 |
+
if not open_:
|
| 103 |
+
return [
|
| 104 |
+
{"name":"Hair Dryer (2Γ)", "category":"critical","state":"OFF","watts":2400,"revenue_rwf":0,"shed_reason":"Business closed"},
|
| 105 |
+
{"name":"Electric Clippers (3Γ)","category":"critical","state":"OFF","watts":120, "revenue_rwf":0,"shed_reason":"Business closed"},
|
| 106 |
+
{"name":"LED Lights", "category":"critical","state":"ON", "watts":20, "revenue_rwf":0},
|
| 107 |
+
{"name":"Standing Fan", "category":"comfort", "state":"OFF","watts":0, "revenue_rwf":0,"shed_reason":"Business closed"},
|
| 108 |
+
{"name":"TV / Display", "category":"comfort", "state":"OFF","watts":0, "revenue_rwf":0,"shed_reason":"Business closed"},
|
| 109 |
+
{"name":"Music System", "category":"luxury", "state":"OFF","watts":0, "revenue_rwf":0,"shed_reason":"Business closed"},
|
| 110 |
+
{"name":"Neon Sign", "category":"luxury", "state":"OFF","watts":0, "revenue_rwf":0,"shed_reason":"Business closed"},
|
| 111 |
+
]
|
| 112 |
+
shed_lux = risk in ("HIGH","MEDIUM")
|
| 113 |
+
shed_com = risk == "HIGH"
|
| 114 |
+
return [
|
| 115 |
+
{"name":"Hair Dryer (2Γ)", "category":"critical","state":"ON", "watts":2400,"revenue_rwf":round(2133*scale)},
|
| 116 |
+
{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON", "watts":120, "revenue_rwf":round(1422*scale)},
|
| 117 |
+
{"name":"LED Lights", "category":"critical","state":"ON", "watts":80, "revenue_rwf":round(711*scale)},
|
| 118 |
+
{"name":"Standing Fan", "category":"comfort","state":"OFF" if shed_com else "ON","watts":0 if shed_com else 75, "revenue_rwf":0 if shed_com else round(285*scale), **({"shed_reason":"HIGH risk β comfort shed"} if shed_com else {})},
|
| 119 |
+
{"name":"TV / Display", "category":"comfort","state":"OFF" if shed_com else "ON","watts":0 if shed_com else 150,"revenue_rwf":0 if shed_com else round(142*scale), **({"shed_reason":"HIGH risk β comfort shed"} if shed_com else {})},
|
| 120 |
+
{"name":"Music System", "category":"luxury", "state":"OFF" if shed_lux else "ON","watts":0 if shed_lux else 80, "revenue_rwf":0, **({"shed_reason":"Risk β₯ MEDIUM β luxury shed"} if shed_lux else {})},
|
| 121 |
+
{"name":"Neon Sign", "category":"luxury", "state":"OFF" if shed_lux else "ON","watts":0 if shed_lux else 40, "revenue_rwf":0, **({"shed_reason":"Risk β₯ MEDIUM β luxury shed"} if shed_lux else {})},
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
def cold_appliances(hour, risk):
|
| 125 |
+
open_ = 6 <= hour <= 20
|
| 126 |
+
peak = 8 <= hour <= 18
|
| 127 |
+
scale = 1.0 if peak else (0.6 if open_ else 0.0)
|
| 128 |
+
fridge_rev = round(1850*scale) if open_ else 0
|
| 129 |
+
pump_rev = round(1100*scale) if open_ else 0
|
| 130 |
+
light_rev = round(740*scale) if open_ else 0
|
| 131 |
+
fan_rev = round(296*scale) if open_ else 0
|
| 132 |
+
tv_rev = round(148*scale) if open_ else 0
|
| 133 |
+
shed_com = risk == "HIGH"
|
| 134 |
+
shed_fan = shed_com or not open_
|
| 135 |
+
shed_tv = shed_com or not open_
|
| 136 |
+
return [
|
| 137 |
+
{"name":"Commercial Refrigerator","category":"critical","state":"ON", "watts":350,"revenue_rwf":fridge_rev or 200,**({"shed_reason":"After-hours β standby mode"} if not open_ else {})},
|
| 138 |
+
{"name":"Water Pump", "category":"critical","state":"ON" if open_ else "OFF","watts":750 if open_ else 0,"revenue_rwf":pump_rev, **({"shed_reason":"After-hours β pump off"} if not open_ else {})},
|
| 139 |
+
{"name":"LED Lights", "category":"critical","state":"ON" if open_ else "OFF","watts":80 if open_ else 0,"revenue_rwf":light_rev,**({"shed_reason":"After-hours β lights off"} if not open_ else {})},
|
| 140 |
+
{"name":"Standing Fan", "category":"comfort", "state":"OFF" if shed_fan else "ON","watts":0 if shed_fan else 75, "revenue_rwf":0 if shed_fan else fan_rev,**({"shed_reason":"HIGH risk β comfort shed" if shed_com else "After-hours"} if shed_fan else {})},
|
| 141 |
+
{"name":"TV / Display", "category":"comfort", "state":"OFF" if shed_tv else "ON","watts":0 if shed_tv else 150,"revenue_rwf":0 if shed_tv else tv_rev, **({"shed_reason":"HIGH risk β comfort shed" if shed_com else "After-hours"} if shed_tv else {})},
|
| 142 |
+
{"name":"Backup Battery Charger","category":"luxury","state":"ON" if (risk=="LOW" and open_) else "OFF","watts":200 if (risk=="LOW" and open_) else 0,"revenue_rwf":0,**({"shed_reason":"Risk β₯ MEDIUM β luxury shed"} if not (risk=="LOW" and open_) else {})},
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
def tailor_appliances(hour, risk):
|
| 146 |
+
open_ = 8 <= hour <= 18
|
| 147 |
+
peak = 9 <= hour <= 16
|
| 148 |
+
scale = 1.0 if peak else (0.6 if open_ else 0.0)
|
| 149 |
+
if not open_:
|
| 150 |
+
return [
|
| 151 |
+
{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0, "revenue_rwf":0,"shed_reason":"Business closed"},
|
| 152 |
+
{"name":"Overlocker", "category":"critical","state":"OFF","watts":0, "revenue_rwf":0,"shed_reason":"Business closed"},
|
| 153 |
+
{"name":"LED Lights", "category":"critical","state":"ON", "watts":20, "revenue_rwf":0},
|
| 154 |
+
{"name":"Iron Press", "category":"comfort", "state":"OFF","watts":0, "revenue_rwf":0,"shed_reason":"Business closed"},
|
| 155 |
+
{"name":"Standing Fan", "category":"comfort", "state":"OFF","watts":0, "revenue_rwf":0,"shed_reason":"Business closed"},
|
| 156 |
+
{"name":"Music System", "category":"luxury", "state":"OFF","watts":0, "revenue_rwf":0,"shed_reason":"Business closed"},
|
| 157 |
+
{"name":"TV / Display", "category":"luxury", "state":"OFF","watts":0, "revenue_rwf":0,"shed_reason":"Business closed"},
|
| 158 |
+
]
|
| 159 |
+
shed_lux = risk in ("HIGH","MEDIUM")
|
| 160 |
+
shed_com = risk == "HIGH"
|
| 161 |
+
shed_iron= risk == "HIGH"
|
| 162 |
+
return [
|
| 163 |
+
{"name":"Sewing Machine (2Γ)","category":"critical","state":"ON","watts":180,"revenue_rwf":round(590*scale)},
|
| 164 |
+
{"name":"Overlocker", "category":"critical","state":"ON","watts":100,"revenue_rwf":round(310*scale)},
|
| 165 |
+
{"name":"LED Lights", "category":"critical","state":"ON","watts":80, "revenue_rwf":round(180*scale)},
|
| 166 |
+
{"name":"Iron Press", "category":"comfort","state":"OFF" if shed_iron else "ON","watts":0 if shed_iron else 1000,"revenue_rwf":0 if shed_iron else round(260*scale),**({"shed_reason":"HIGH risk β heavy load shed"} if shed_iron else {})},
|
| 167 |
+
{"name":"Standing Fan", "category":"comfort","state":"OFF" if shed_com else "ON","watts":0 if shed_com else 75, "revenue_rwf":0 if shed_com else round(120*scale),**({"shed_reason":"HIGH risk β comfort shed"} if shed_com else {})},
|
| 168 |
+
{"name":"Music System", "category":"luxury", "state":"OFF" if shed_lux else "ON","watts":0 if shed_lux else 80, "revenue_rwf":0,**({"shed_reason":"Risk β₯ MEDIUM β luxury shed"} if shed_lux else {})},
|
| 169 |
+
{"name":"TV / Display", "category":"luxury", "state":"OFF" if shed_lux else "ON","watts":0 if shed_lux else 150, "revenue_rwf":0,**({"shed_reason":"Risk β₯ MEDIUM β luxury shed"} if shed_lux else {})},
|
| 170 |
+
]
|
| 171 |
+
|
| 172 |
+
PLANS = {
|
| 173 |
+
"salon": {
|
| 174 |
+
"label": "π Beauty Salon",
|
| 175 |
+
"summary": {"total_revenue_plan_rwf":93850,"total_revenue_naive_rwf":101790,"net_benefit_rwf":12418,"hours_with_shed":24},
|
| 176 |
+
"fn": salon_appliances,
|
| 177 |
+
},
|
| 178 |
+
"cold_room": {
|
| 179 |
+
"label": "π§ Cold Room",
|
| 180 |
+
"summary": {"total_revenue_plan_rwf":118000,"total_revenue_naive_rwf":125000,"net_benefit_rwf":18000,"hours_with_shed":16},
|
| 181 |
+
"fn": cold_appliances,
|
| 182 |
+
},
|
| 183 |
+
"tailor": {
|
| 184 |
+
"label": "π§΅ Tailor Shop",
|
| 185 |
+
"summary": {"total_revenue_plan_rwf":42000,"total_revenue_naive_rwf":48000,"net_benefit_rwf":3600,"hours_with_shed":14},
|
| 186 |
+
"fn": tailor_appliances,
|
| 187 |
+
},
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
RISK_COLOR = {"HIGH": "#ef4444", "MEDIUM": "#f97316", "LOW": "#22c55e"}
|
| 191 |
+
|
| 192 |
+
# ββ Sidebar βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 193 |
+
with st.sidebar:
|
| 194 |
+
st.markdown("## β‘ Grid Outage Forecaster")
|
| 195 |
+
st.markdown("<span style='color:#8892b0;font-size:12px'>T2.3 Β· AIMS KTT Hackathon 2026 Β· Kigali, Rwanda</span>", unsafe_allow_html=True)
|
| 196 |
+
st.divider()
|
| 197 |
+
|
| 198 |
+
st.markdown("### Model Metrics")
|
| 199 |
+
st.metric("Brier Score", "0.176")
|
| 200 |
+
st.metric("MAE (min)", "61.2")
|
| 201 |
+
st.metric("Avg Lead Time", "2.79h")
|
| 202 |
+
st.divider()
|
| 203 |
+
|
| 204 |
+
st.markdown("### Business")
|
| 205 |
+
biz_key = st.radio(
|
| 206 |
+
"Select business",
|
| 207 |
+
options=list(PLANS.keys()),
|
| 208 |
+
format_func=lambda k: PLANS[k]["label"],
|
| 209 |
+
label_visibility="collapsed",
|
| 210 |
+
)
|
| 211 |
+
st.divider()
|
| 212 |
+
|
| 213 |
+
biz = PLANS[biz_key]
|
| 214 |
+
s = biz["summary"]
|
| 215 |
+
st.markdown("### Plan Summary")
|
| 216 |
+
st.metric("Net Benefit (RWF)", f"{s['net_benefit_rwf']:,}")
|
| 217 |
+
st.metric("Expected Rev (RWF)", f"{s['total_revenue_plan_rwf']:,}")
|
| 218 |
+
high_h = sum(1 for f in FORECAST if f["risk_level"] == "HIGH")
|
| 219 |
+
st.metric("HIGH Risk Hours", high_h)
|
| 220 |
+
st.metric("Hours with Shed", s["hours_with_shed"])
|
| 221 |
+
|
| 222 |
+
# ββ Main tabs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
+
tab_forecast, tab_plan, tab_sms, tab_about = st.tabs(
|
| 224 |
+
["π Forecast", "π Appliance Plan", "π± SMS Digest", "βΉοΈ About"]
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
# ββ FORECAST TAB ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 228 |
+
with tab_forecast:
|
| 229 |
+
st.markdown("### 24-Hour Outage Probability Forecast")
|
| 230 |
+
|
| 231 |
+
hours = [f["hour"] for f in FORECAST]
|
| 232 |
+
p_out = [f["p_outage"] for f in FORECAST]
|
| 233 |
+
p_low = [f["p_outage_low"] for f in FORECAST]
|
| 234 |
+
p_high = [f["p_outage_high"] for f in FORECAST]
|
| 235 |
+
risk_levels = [f["risk_level"] for f in FORECAST]
|
| 236 |
+
bar_colors = [RISK_COLOR[r] for r in risk_levels]
|
| 237 |
+
|
| 238 |
+
fig = go.Figure()
|
| 239 |
+
|
| 240 |
+
# Risk background zones (coloured bar under chart)
|
| 241 |
+
for f in FORECAST:
|
| 242 |
+
col = {"HIGH":"rgba(239,68,68,.10)","MEDIUM":"rgba(249,115,22,.07)","LOW":"rgba(34,197,94,.04)"}[f["risk_level"]]
|
| 243 |
+
fig.add_vrect(x0=f["hour"]-.5, x1=f["hour"]+.5, fillcolor=col, line_width=0, layer="below")
|
| 244 |
+
|
| 245 |
+
# Uncertainty band
|
| 246 |
+
fig.add_trace(go.Scatter(
|
| 247 |
+
x=hours + hours[::-1],
|
| 248 |
+
y=p_high + p_low[::-1],
|
| 249 |
+
fill="toself", fillcolor="rgba(99,102,241,.18)",
|
| 250 |
+
line=dict(color="rgba(0,0,0,0)"),
|
| 251 |
+
hoverinfo="skip", name="Uncertainty band",
|
| 252 |
+
))
|
| 253 |
+
|
| 254 |
+
# Main line
|
| 255 |
+
fig.add_trace(go.Scatter(
|
| 256 |
+
x=hours, y=p_out,
|
| 257 |
+
mode="lines+markers",
|
| 258 |
+
line=dict(color="#6366f1", width=2.5),
|
| 259 |
+
marker=dict(color=bar_colors, size=8, line=dict(color="#0f1117", width=1)),
|
| 260 |
+
name="P(outage)",
|
| 261 |
+
hovertemplate="Hour %{x}:00<br>P(outage)=%{y:.1%}<extra></extra>",
|
| 262 |
+
))
|
| 263 |
+
|
| 264 |
+
# HIGH threshold line
|
| 265 |
+
fig.add_hline(y=0.25, line=dict(color="#ef4444", dash="dash", width=1),
|
| 266 |
+
annotation_text="HIGH threshold", annotation_position="top left",
|
| 267 |
+
annotation_font_color="#ef4444")
|
| 268 |
+
|
| 269 |
+
fig.update_layout(
|
| 270 |
+
paper_bgcolor="#1a1d27", plot_bgcolor="#1a1d27",
|
| 271 |
+
font=dict(color="#e8eaf6", size=12),
|
| 272 |
+
xaxis=dict(title="Hour of day", gridcolor="#2e3350", tickvals=list(range(0,24,2))),
|
| 273 |
+
yaxis=dict(title="P(outage)", gridcolor="#2e3350", tickformat=".0%", range=[0, 0.55]),
|
| 274 |
+
legend=dict(orientation="h", y=1.08, bgcolor="rgba(0,0,0,0)"),
|
| 275 |
+
margin=dict(l=10, r=10, t=10, b=10),
|
| 276 |
+
height=320,
|
| 277 |
+
)
|
| 278 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 279 |
+
|
| 280 |
+
# ββ Hour grid βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 281 |
+
st.markdown("### Hourly Risk β click a cell to drill into plan")
|
| 282 |
+
cols = st.columns(12)
|
| 283 |
+
for i, f in enumerate(FORECAST):
|
| 284 |
+
col_idx = i % 12
|
| 285 |
+
with cols[col_idx]:
|
| 286 |
+
risk = f["risk_level"]
|
| 287 |
+
color = RISK_COLOR[risk]
|
| 288 |
+
pct = f"{f['p_outage']*100:.0f}%"
|
| 289 |
+
st.markdown(f"""
|
| 290 |
+
<div style='background:#1a1d27;border:1px solid #2e3350;border-radius:6px;
|
| 291 |
+
padding:6px 4px;text-align:center;margin-bottom:4px;'>
|
| 292 |
+
<div style='font-size:10px;color:#8892b0'>{f["hour"]}h</div>
|
| 293 |
+
<div style='font-size:14px;font-weight:700;color:{color}'>{pct}</div>
|
| 294 |
+
<div style='margin-top:2px'><span class='badge badge-{risk.lower()}'>{risk}</span></div>
|
| 295 |
+
</div>""", unsafe_allow_html=True)
|
| 296 |
+
|
| 297 |
+
cols2 = st.columns(12)
|
| 298 |
+
for i, f in enumerate(FORECAST):
|
| 299 |
+
with cols2[i % 12]:
|
| 300 |
+
pass # second row of 12 hours already handled above
|
| 301 |
+
|
| 302 |
+
# Second row (hours 12β23)
|
| 303 |
+
st.markdown("")
|
| 304 |
+
|
| 305 |
+
# ββ PLAN TAB ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 306 |
+
with tab_plan:
|
| 307 |
+
st.markdown("### π Appliance Plan")
|
| 308 |
+
|
| 309 |
+
hour_idx = st.slider(
|
| 310 |
+
"Select hour",
|
| 311 |
+
min_value=0, max_value=23, value=0,
|
| 312 |
+
format="%d:00",
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
fc = FORECAST[hour_idx]
|
| 316 |
+
appliances = biz["fn"](hour_idx, fc["risk_level"])
|
| 317 |
+
risk = fc["risk_level"]
|
| 318 |
+
|
| 319 |
+
# Hour info header
|
| 320 |
+
risk_color = RISK_COLOR[risk]
|
| 321 |
+
st.markdown(f"""
|
| 322 |
+
<div class='plan-header'>
|
| 323 |
+
<b>Hour {hour_idx}</b> Β· {fc['timestamp'].split()[1]}
|
| 324 |
+
<span class='badge badge-{risk.lower()}'>{risk}</span>
|
| 325 |
+
P(outage) = <b>{fc['p_outage']*100:.1f}%</b>
|
| 326 |
+
Exp. duration = <b>{fc['expected_duration_min']:.0f} min</b>
|
| 327 |
+
</div>
|
| 328 |
+
""", unsafe_allow_html=True)
|
| 329 |
+
|
| 330 |
+
# Appliance cards in 2 columns
|
| 331 |
+
left_col, right_col = st.columns(2)
|
| 332 |
+
for i, ap in enumerate(appliances):
|
| 333 |
+
target = left_col if i % 2 == 0 else right_col
|
| 334 |
+
is_off = ap["state"] == "OFF"
|
| 335 |
+
opacity = "opacity:.65;" if is_off else ""
|
| 336 |
+
shed = f"<div class='ap-shed'>β {ap['shed_reason']}</div>" if "shed_reason" in ap else ""
|
| 337 |
+
rev_html = f"<div class='ap-rev'>{ap['revenue_rwf']:,} RWF/h</div>" if ap["state"] == "ON" and ap["revenue_rwf"] > 0 else "<div style='color:#6b7280'>β</div>"
|
| 338 |
+
with target:
|
| 339 |
+
st.markdown(f"""
|
| 340 |
+
<div class='ap-card{"" if not is_off else " off"}' style='{opacity}'>
|
| 341 |
+
<div style='display:flex;justify-content:space-between;align-items:flex-start'>
|
| 342 |
+
<div>
|
| 343 |
+
<div class='ap-name'>{ap['name']}</div>
|
| 344 |
+
<div class='ap-meta'>
|
| 345 |
+
<span class='badge badge-{ap['category']}'>{ap['category']}</span>
|
| 346 |
+
<span class='badge badge-{ap['state'].lower()}'>{ap['state']}</span>
|
| 347 |
+
</div>
|
| 348 |
+
{shed}
|
| 349 |
+
</div>
|
| 350 |
+
<div class='ap-right'>
|
| 351 |
+
<div style='font-size:11px;color:#8892b0'>{ap['watts']}W</div>
|
| 352 |
+
{rev_html}
|
| 353 |
+
</div>
|
| 354 |
+
</div>
|
| 355 |
+
</div>""", unsafe_allow_html=True)
|
| 356 |
+
|
| 357 |
+
st.markdown("""
|
| 358 |
+
<div style='background:#1a1d27;border:1px solid #2e3350;border-radius:8px;
|
| 359 |
+
padding:12px;font-size:12px;color:#8892b0;margin-top:8px;'>
|
| 360 |
+
<b style='color:#e8eaf6'>Shedding Logic:</b>
|
| 361 |
+
Luxury β Comfort β Critical (never shed during peak unless P > 0.50).
|
| 362 |
+
Within category: lowest revenue shed first. Critical always ON during business peak hours.
|
| 363 |
+
</div>""", unsafe_allow_html=True)
|
| 364 |
+
|
| 365 |
+
# ββ SMS TAB βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 366 |
+
with tab_sms:
|
| 367 |
+
st.markdown("### π± Morning Digest β Feature Phone SMS")
|
| 368 |
+
st.markdown("<span style='color:#8892b0;font-size:12px'>Sent at 06:30 CAT. Max 3 messages Γ 160 chars. Works on any GSM phone. No internet required. Language: Kinyarwanda/English mix for maximum reach.</span>", unsafe_allow_html=True)
|
| 369 |
+
st.markdown("")
|
| 370 |
+
|
| 371 |
+
for i, msg in enumerate(SMS):
|
| 372 |
+
st.markdown(f"""
|
| 373 |
+
<div class='sms-box'>
|
| 374 |
+
<div style='display:flex;justify-content:space-between;margin-bottom:6px'>
|
| 375 |
+
<span style='font-size:11px;font-weight:700;color:#6366f1'>SMS {i+1}/3</span>
|
| 376 |
+
<span style='font-size:10px;color:#8892b0'>{len(msg)}/160 chars</span>
|
| 377 |
+
</div>
|
| 378 |
+
{msg}
|
| 379 |
+
</div>""", unsafe_allow_html=True)
|
| 380 |
+
|
| 381 |
+
st.markdown("""
|
| 382 |
+
<div class='sms-box' style='border-color:#6366f1;margin-top:16px;'>
|
| 383 |
+
<div style='font-size:12px;font-weight:700;color:#6366f1;margin-bottom:8px'>π Offline Fallback Protocol</div>
|
| 384 |
+
<div style='font-size:12px;color:#8892b0;line-height:1.7'>
|
| 385 |
+
<b style='color:#e8eaf6'>If no internet refresh by 13:00:</b> Device shows last cached plan with
|
| 386 |
+
a red β οΈ staleness banner. Risk budget: plan valid for <b style='color:#f97316'>6 hours</b>
|
| 387 |
+
from generation time. After 6h, all HIGH-risk flags remain but MEDIUM degrades to LOW (overly cautious).
|
| 388 |
+
Maximum acceptable staleness: <b style='color:#ef4444'>8 hours</b>.
|
| 389 |
+
Owner sees: "PLAN STALE β use generator, call 0788-GRID."
|
| 390 |
+
</div>
|
| 391 |
+
</div>
|
| 392 |
+
<div class='sms-box' style='border-color:#22c55e;margin-top:10px;'>
|
| 393 |
+
<div style='font-size:12px;font-weight:700;color:#22c55e;margin-bottom:8px'>π Illiteracy Adaptation β Voice + LED Relay</div>
|
| 394 |
+
<div style='font-size:12px;color:#8892b0;line-height:1.7'>
|
| 395 |
+
<b style='color:#e8eaf6'>Design choice: Colored LED relay board</b> (3 LEDs per appliance slot).<br>
|
| 396 |
+
π’ GREEN = ON safe Β· π‘ YELLOW = shed if load high Β· π΄ RED = OFF now.<br>
|
| 397 |
+
Board connects via GPIO to a βUSD 8 ESP32 running cached plan. No reading required.
|
| 398 |
+
Physical override switch lets owner override any LED. $8 hardware cost, zero ongoing data cost.
|
| 399 |
+
</div>
|
| 400 |
+
</div>
|
| 401 |
+
""", unsafe_allow_html=True)
|
| 402 |
+
|
| 403 |
+
# ββ ABOUT TAB βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 404 |
+
with tab_about:
|
| 405 |
+
st.markdown("### Technical Notes")
|
| 406 |
+
col1, col2 = st.columns(2)
|
| 407 |
+
|
| 408 |
+
with col1:
|
| 409 |
+
st.markdown("""
|
| 410 |
+
<div class='sms-box'>
|
| 411 |
+
<div style='font-size:12px;font-weight:700;color:#6366f1;margin-bottom:6px'>Model</div>
|
| 412 |
+
<div style='font-size:12px;color:#8892b0;line-height:1.7'>
|
| 413 |
+
<b style='color:#e8eaf6'>LightGBM</b> classifier for P(outage) + regressor for E[duration | outage].<br>
|
| 414 |
+
Features: lagged load (1h, 2h, 24h, 48h), rolling stats, weather (temp, humidity, rain, wind),
|
| 415 |
+
temporal (hour, DOW, month, peak flags, rainy season). Training: 150-day window.
|
| 416 |
+
</div>
|
| 417 |
+
</div>
|
| 418 |
+
""", unsafe_allow_html=True)
|
| 419 |
+
|
| 420 |
+
st.markdown("""
|
| 421 |
+
<div class='sms-box' style='margin-top:10px'>
|
| 422 |
+
<div style='font-size:12px;font-weight:700;color:#6366f1;margin-bottom:6px'>Hardest Trade-off</div>
|
| 423 |
+
<div style='font-size:12px;color:#8892b0;line-height:1.7'>
|
| 424 |
+
Chose LightGBM over Prophet: faster retrain, handles irregular time steps,
|
| 425 |
+
natively supports tabular weather features. Trade-off: less interpretable
|
| 426 |
+
seasonality decomposition. Compensated with explicit hour/DOW/month features
|
| 427 |
+
and SHAP values available in eval notebook.
|
| 428 |
+
</div>
|
| 429 |
+
</div>
|
| 430 |
+
""", unsafe_allow_html=True)
|
| 431 |
+
|
| 432 |
+
with col2:
|
| 433 |
+
st.markdown("""
|
| 434 |
+
<div class='sms-box'>
|
| 435 |
+
<div style='font-size:12px;font-weight:700;color:#6366f1;margin-bottom:6px'>Performance</div>
|
| 436 |
+
<div style='font-size:12px;color:#8892b0;line-height:1.7'>
|
| 437 |
+
Brier score: <b style='color:#22c55e'>0.1756</b> (naΓ―ve base rate = ~0.212)<br>
|
| 438 |
+
Duration MAE: <b style='color:#22c55e'>61.2 min</b><br>
|
| 439 |
+
Avg lead time on true outages: <b style='color:#22c55e'>2.79h</b><br>
|
| 440 |
+
Inference latency: <b style='color:#22c55e'><300ms CPU</b><br>
|
| 441 |
+
Retraining time: <b style='color:#22c55e'><10 min</b>
|
| 442 |
+
</div>
|
| 443 |
+
</div>
|
| 444 |
+
""", unsafe_allow_html=True)
|
| 445 |
+
|
| 446 |
+
st.markdown("""
|
| 447 |
+
<div class='sms-box' style='margin-top:10px'>
|
| 448 |
+
<div style='font-size:12px;font-weight:700;color:#6366f1;margin-bottom:6px'>Constraints Met</div>
|
| 449 |
+
<div style='font-size:12px;color:#8892b0;line-height:1.7'>
|
| 450 |
+
β
CPU-only Β· β
<10 min retrain Β· β
<300ms serve<br>
|
| 451 |
+
β
Feature phone SMS digest Β· β
Offline fallback protocol<br>
|
| 452 |
+
β
Illiteracy adaptation Β· β
3 business archetypes<br>
|
| 453 |
+
β
Critical-before-luxury rule
|
| 454 |
+
</div>
|
| 455 |
+
</div>
|
| 456 |
+
""", unsafe_allow_html=True)
|
| 457 |
+
|
| 458 |
+
st.markdown("""
|
| 459 |
+
<div style='text-align:center;color:#8892b0;font-size:11px;padding:20px 0 10px'>
|
| 460 |
+
T2.3 Β· Grid Outage Forecaster + Appliance Prioritizer Β· AIMS KTT Hackathon 2026 Β· CPU-only
|
| 461 |
+
</div>""", unsafe_allow_html=True)
|
src/appliances.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "fridge",
|
| 4 |
+
"name": "Commercial Refrigerator",
|
| 5 |
+
"category": "critical",
|
| 6 |
+
"watts_avg": 350,
|
| 7 |
+
"start_up_spike_w": 700,
|
| 8 |
+
"revenue_if_running_rwf_per_h": 2500
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"id": "hair_dryer",
|
| 12 |
+
"name": "Hair Dryer (2\u00d7)",
|
| 13 |
+
"category": "critical",
|
| 14 |
+
"watts_avg": 2400,
|
| 15 |
+
"start_up_spike_w": 2500,
|
| 16 |
+
"revenue_if_running_rwf_per_h": 3000
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"id": "clippers",
|
| 20 |
+
"name": "Electric Clippers (3\u00d7)",
|
| 21 |
+
"category": "critical",
|
| 22 |
+
"watts_avg": 120,
|
| 23 |
+
"start_up_spike_w": 150,
|
| 24 |
+
"revenue_if_running_rwf_per_h": 2000
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"id": "water_pump",
|
| 28 |
+
"name": "Water Pump",
|
| 29 |
+
"category": "critical",
|
| 30 |
+
"watts_avg": 750,
|
| 31 |
+
"start_up_spike_w": 1500,
|
| 32 |
+
"revenue_if_running_rwf_per_h": 1500
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"id": "lights",
|
| 36 |
+
"name": "LED Lights",
|
| 37 |
+
"category": "critical",
|
| 38 |
+
"watts_avg": 80,
|
| 39 |
+
"start_up_spike_w": 80,
|
| 40 |
+
"revenue_if_running_rwf_per_h": 1000
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"id": "air_con",
|
| 44 |
+
"name": "Air Conditioner",
|
| 45 |
+
"category": "comfort",
|
| 46 |
+
"watts_avg": 1500,
|
| 47 |
+
"start_up_spike_w": 3000,
|
| 48 |
+
"revenue_if_running_rwf_per_h": 800
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"id": "fan",
|
| 52 |
+
"name": "Standing Fan",
|
| 53 |
+
"category": "comfort",
|
| 54 |
+
"watts_avg": 75,
|
| 55 |
+
"start_up_spike_w": 80,
|
| 56 |
+
"revenue_if_running_rwf_per_h": 400
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"id": "tv",
|
| 60 |
+
"name": "TV / Display Screen",
|
| 61 |
+
"category": "comfort",
|
| 62 |
+
"watts_avg": 150,
|
| 63 |
+
"start_up_spike_w": 160,
|
| 64 |
+
"revenue_if_running_rwf_per_h": 200
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"id": "music",
|
| 68 |
+
"name": "Music System",
|
| 69 |
+
"category": "luxury",
|
| 70 |
+
"watts_avg": 200,
|
| 71 |
+
"start_up_spike_w": 220,
|
| 72 |
+
"revenue_if_running_rwf_per_h": 100
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"id": "neon_sign",
|
| 76 |
+
"name": "Neon Sign",
|
| 77 |
+
"category": "luxury",
|
| 78 |
+
"watts_avg": 60,
|
| 79 |
+
"start_up_spike_w": 65,
|
| 80 |
+
"revenue_if_running_rwf_per_h": 50
|
| 81 |
+
}
|
| 82 |
+
]
|
src/businesses.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "salon",
|
| 4 |
+
"name": "Beauty Salon (Kigali)",
|
| 5 |
+
"archetype": "salon",
|
| 6 |
+
"description": "4-chair salon, open 07:00\u201320:00, 6 days/week",
|
| 7 |
+
"generator_kva": 2.0,
|
| 8 |
+
"appliance_ids": [
|
| 9 |
+
"hair_dryer",
|
| 10 |
+
"clippers",
|
| 11 |
+
"lights",
|
| 12 |
+
"fan",
|
| 13 |
+
"tv",
|
| 14 |
+
"music",
|
| 15 |
+
"neon_sign"
|
| 16 |
+
],
|
| 17 |
+
"peak_hours": [
|
| 18 |
+
8,
|
| 19 |
+
9,
|
| 20 |
+
10,
|
| 21 |
+
15,
|
| 22 |
+
16,
|
| 23 |
+
17,
|
| 24 |
+
18
|
| 25 |
+
],
|
| 26 |
+
"monthly_revenue_rwf": 1800000
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"id": "cold_room",
|
| 30 |
+
"name": "Cold Room / Butchery",
|
| 31 |
+
"archetype": "cold_room",
|
| 32 |
+
"description": "Meat storage + retail, 05:00\u201322:00, 7 days",
|
| 33 |
+
"generator_kva": 3.5,
|
| 34 |
+
"appliance_ids": [
|
| 35 |
+
"fridge",
|
| 36 |
+
"lights",
|
| 37 |
+
"water_pump",
|
| 38 |
+
"fan",
|
| 39 |
+
"tv"
|
| 40 |
+
],
|
| 41 |
+
"peak_hours": [
|
| 42 |
+
5,
|
| 43 |
+
6,
|
| 44 |
+
7,
|
| 45 |
+
17,
|
| 46 |
+
18,
|
| 47 |
+
19,
|
| 48 |
+
20
|
| 49 |
+
],
|
| 50 |
+
"monthly_revenue_rwf": 2500000
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"id": "tailor",
|
| 54 |
+
"name": "Tailor Shop",
|
| 55 |
+
"archetype": "tailor",
|
| 56 |
+
"description": "3 sewing machines + ironing, 08:00\u201318:00, 6 days",
|
| 57 |
+
"generator_kva": 1.5,
|
| 58 |
+
"appliance_ids": [
|
| 59 |
+
"lights",
|
| 60 |
+
"fan",
|
| 61 |
+
"music",
|
| 62 |
+
"tv"
|
| 63 |
+
],
|
| 64 |
+
"peak_hours": [
|
| 65 |
+
9,
|
| 66 |
+
10,
|
| 67 |
+
11,
|
| 68 |
+
14,
|
| 69 |
+
15,
|
| 70 |
+
16
|
| 71 |
+
],
|
| 72 |
+
"monthly_revenue_rwf": 900000
|
| 73 |
+
}
|
| 74 |
+
]
|
src/digest_spec.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# digest_spec.md Β· T2.3 Β· Grid Outage Forecaster + Appliance Prioritizer
|
| 2 |
+
|
| 3 |
+
## Product & Business Adaptation
|
| 4 |
+
|
| 5 |
+
**Challenge:** Design an actionable outage forecast system for low-bandwidth, intermittent-power, non-smartphone users in Kigali's SME sector.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. Morning Digest β Feature Phone SMS (3 Γ 160 chars)
|
| 10 |
+
|
| 11 |
+
**Delivery:** Automated SMS sent at **06:30 CAT** via Africa's Talking or MTN Rwanda bulk SMS API (~RWF 15/SMS, total RWF 45/day). The server runs on a RWF 2,500/month DigitalOcean droplet shared across 200+ subscribers = **< RWF 30/business/day** all-in.
|
| 12 |
+
|
| 13 |
+
**User:** Salon owner, Kigali Nyamirambo district. Phone: Nokia 3310 (no internet). Reads English and Kinyarwanda. Opens the day at 07:00.
|
| 14 |
+
|
| 15 |
+
**Workflow:**
|
| 16 |
+
1. Forecaster runs at 06:00 CAT, generates 24h plan for each business archetype.
|
| 17 |
+
2. SMS gateway sends 3 texts to registered phone numbers.
|
| 18 |
+
3. Owner reads SMS over morning tea, decides whether to fuel the generator.
|
| 19 |
+
|
| 20 |
+
**Three SMS templates (salon archetype, High-risk day):**
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
SMS 1/3 (96 chars):
|
| 24 |
+
UMURIRO FORECAST 24H: Risk=HIGH at 0h,1h,3h.
|
| 25 |
+
Shed: Standing Fan+TV. Est.save: 12,418RWF.
|
| 26 |
+
Stay alert!
|
| 27 |
+
|
| 28 |
+
SMS 2/3 (102 chars):
|
| 29 |
+
PLAN: Turn OFF Standing Fan+TV during risk hrs
|
| 30 |
+
(0h,1h,3h). Keep dryer+clippers+lights ON.
|
| 31 |
+
Generator ready?
|
| 32 |
+
|
| 33 |
+
SMS 3/3 (102 chars):
|
| 34 |
+
If no signal by 13h, use YESTERDAY plan. Risk
|
| 35 |
+
valid 6h. Call 0788-GRID for live update.
|
| 36 |
+
Good business!
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
**Design constraints met:**
|
| 40 |
+
- `UMURIRO` (Kinyarwanda for "electricity/fire") β immediately scannable
|
| 41 |
+
- Key info in first 30 chars (visible in notification preview on feature phone)
|
| 42 |
+
- No URLs, no app required
|
| 43 |
+
- Action verbs: "Turn OFF", "Keep ON", "Call"
|
| 44 |
+
- Revenue in RWF (not percentages) β concrete and motivating
|
| 45 |
+
- All 3 SMS within 160 chars including spaces
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## 2. Offline / No-Internet-Refresh Protocol
|
| 50 |
+
|
| 51 |
+
**Scenario:** Salon owner gets SMS at 06:30. Internet drops at 09:00. Forecast cannot refresh at 13:00.
|
| 52 |
+
|
| 53 |
+
**What the device shows:**
|
| 54 |
+
- The `lite_ui.html` page (if loaded before dropout) shows a red banner:
|
| 55 |
+
> β οΈ **PLAN STALE β Last updated 06:15. Risk valid until 14:15. After that: treat all hours as HIGH risk. Call 0788-GRID.**
|
| 56 |
+
- The appliance plan remains visible with all cells greyed-out and a staleness timestamp.
|
| 57 |
+
- A JavaScript timer increments a "stale for Xh Ym" counter visibly.
|
| 58 |
+
|
| 59 |
+
**Risk budget for stale plan:**
|
| 60 |
+
- **0β4 hours stale:** Trust fully. LightGBM predictions are stable over short horizons.
|
| 61 |
+
- **4β6 hours stale:** Trust HIGH-risk hours. Downgrade MEDIUM β treat conservatively (as HIGH). LOW β ignore.
|
| 62 |
+
- **6β8 hours stale:** Trust only the direction (expect high/low outage day). Specific hour timing unreliable.
|
| 63 |
+
- **> 8 hours stale:** Stop trusting. Owner sees: *"PLAN EXPIRED β use generator for critical appliances only."*
|
| 64 |
+
|
| 65 |
+
**Numbers:** The model's 30-day eval shows average forecast skill decays with horizon. At +6h the Brier score degrades from 0.176 to ~0.24 (estimated from rolling window variance). This is our 6h staleness threshold.
|
| 66 |
+
|
| 67 |
+
**Justification:** A stale plan that says "HIGH risk at 17h" based on yesterday's load pattern is still ~68% reliable at the 6h mark (based on autocorrelation of outage events in grid_history.csv). Better to act conservatively than to have cold room contents spoil.
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## 3. Illiteracy Adaptation β Colored LED Relay Board
|
| 72 |
+
|
| 73 |
+
**Design choice: Physical LED relay board** (not voice, not icon-only UI)
|
| 74 |
+
|
| 75 |
+
**Why LED over voice:**
|
| 76 |
+
- Voice requires a speaker, power, and software synthesis (adds cost and failure points)
|
| 77 |
+
- Icons require at least a feature phone screen (not all workers carry one)
|
| 78 |
+
- LEDs are universal β red/green/yellow cross every language and literacy level
|
| 79 |
+
- A relay board physically controls the appliance β no action required from the user
|
| 80 |
+
|
| 81 |
+
**Hardware spec (unit cost: ~USD 8):**
|
| 82 |
+
- ESP32 microcontroller (USD 3)
|
| 83 |
+
- 3-channel relay board (USD 2)
|
| 84 |
+
- RGB LEDs Γ 7 appliance slots (USD 1)
|
| 85 |
+
- 3D-printed enclosure with appliance labels (icon + color sticker) (USD 2)
|
| 86 |
+
- Total BOM: **USD 8 per installation**
|
| 87 |
+
|
| 88 |
+
**LED behavior:**
|
| 89 |
+
| LED Color | Meaning | Action |
|
| 90 |
+
|-----------|---------|--------|
|
| 91 |
+
| π’ GREEN | Safe to run β LOW risk | No action |
|
| 92 |
+
| π‘ YELLOW | Shed if load is high β MEDIUM risk | Optional off |
|
| 93 |
+
| π΄ RED | Must switch OFF β HIGH risk | Turn off now |
|
| 94 |
+
| βͺ WHITE (flashing) | Plan stale / no signal | Call for update |
|
| 95 |
+
|
| 96 |
+
**Workflow:**
|
| 97 |
+
1. ESP32 receives plan over WiFi/BLE from hub (or pre-cached for 24h).
|
| 98 |
+
2. LED color updates every hour automatically.
|
| 99 |
+
3. Staff member (no literacy required) sees which appliance slots are RED and switches them off.
|
| 100 |
+
4. Physical override button per slot: owner can override any decision and it logs to the hub.
|
| 101 |
+
|
| 102 |
+
**Offline behaviour:** ESP32 has 24h of cached plan in flash memory. If WiFi drops, it runs from cache and starts flashing WHITE after 6h staleness.
|
| 103 |
+
|
| 104 |
+
**Business case:** At 200 salons in Kigali, total hardware cost = USD 1,600. Monthly SMS cost = RWF 45 Γ 30 Γ 200 = RWF 270,000 (~USD 190/month). Revenue protected per salon per week during typical outage week: ~RWF 12,400 Γ 5 = RWF 62,000/week. Payback period: **< 1 week per salon.**
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## 4. Revenue Calculation β Plan vs NaΓ―ve (Salon, Typical Outage Week)
|
| 109 |
+
|
| 110 |
+
**Assumptions:**
|
| 111 |
+
- Typical outage week: 5 outage events, avg 90 min each
|
| 112 |
+
- Salon runs 07:00β20:00 = 13h/day, 6 days/week
|
| 113 |
+
- Total critical appliance revenue: ~RWF 8,000/h (dryer + clippers + lights)
|
| 114 |
+
- NaΓ―ve operation: all appliances ON, revenue lost during outage = 90min Γ 5 Γ (RWF 8,000/h Γ 1.5h) = **RWF 60,000 lost/week**
|
| 115 |
+
- With plan: luxury/comfort shed during HIGH-risk hours saves ~15% of outage disruption overhead + avoids equipment startup costs
|
| 116 |
+
- Net benefit per 24h forecast day: **RWF 12,418** (from model output)
|
| 117 |
+
- Net benefit per typical 5-outage week: **~RWF 62,000 saved vs naΓ―ve**
|
| 118 |
+
|
| 119 |
+
This matches the hardware payback calculation above β the LED board pays for itself in under one week.
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## 5. Next 90 Days (if selected)
|
| 124 |
+
|
| 125 |
+
- **Month 1:** Deploy pilot with 20 salons in Nyamirambo/Kimironko, Kigali. Real grid data via REG (Rwanda Energy Group) API partnership. Validate Brier score on live data.
|
| 126 |
+
- **Month 2:** Launch SMS subscription service at RWF 500/month/business. Expand to cold rooms (highest revenue-at-risk). Integrate neighbor-signal crowd reports (stretch goal).
|
| 127 |
+
- **Month 3:** Deploy LED relay boards at 50 locations. Open API for generator rental companies to integrate outage forecasts into dispatch planning.
|
src/eda_plots.png
ADDED
|
src/eval.ipynb
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "a4b8f307",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# T2.3 Β· Evaluation Notebook\n",
|
| 9 |
+
"**Rolling 30-day held-out evaluation** β Brier score, Duration MAE, Lead Time\n",
|
| 10 |
+
"\n",
|
| 11 |
+
"AIMS KTT Hackathon 2026"
|
| 12 |
+
]
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"cell_type": "code",
|
| 16 |
+
"execution_count": null,
|
| 17 |
+
"id": "a251a2c7",
|
| 18 |
+
"metadata": {},
|
| 19 |
+
"outputs": [],
|
| 20 |
+
"source": [
|
| 21 |
+
"# Install deps\n",
|
| 22 |
+
"!pip install pandas numpy scikit-learn lightgbm matplotlib -q"
|
| 23 |
+
]
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"cell_type": "code",
|
| 27 |
+
"execution_count": null,
|
| 28 |
+
"id": "d54cb386",
|
| 29 |
+
"metadata": {},
|
| 30 |
+
"outputs": [],
|
| 31 |
+
"source": [
|
| 32 |
+
"import pandas as pd\n",
|
| 33 |
+
"import numpy as np\n",
|
| 34 |
+
"import matplotlib.pyplot as plt\n",
|
| 35 |
+
"from forecaster import Forecaster, rolling_eval, build_features, FEATURE_COLS\n",
|
| 36 |
+
"from prioritizer import plan, load_data, format_digest\n",
|
| 37 |
+
"\n",
|
| 38 |
+
"plt.style.use(\"dark_background\")\n",
|
| 39 |
+
"print(\"Imports OK\")"
|
| 40 |
+
]
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"cell_type": "markdown",
|
| 44 |
+
"id": "c0c39297",
|
| 45 |
+
"metadata": {},
|
| 46 |
+
"source": [
|
| 47 |
+
"## 1. Data Overview"
|
| 48 |
+
]
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"cell_type": "code",
|
| 52 |
+
"execution_count": null,
|
| 53 |
+
"id": "c5395955",
|
| 54 |
+
"metadata": {},
|
| 55 |
+
"outputs": [],
|
| 56 |
+
"source": [
|
| 57 |
+
"df = pd.read_csv(\"grid_history.csv\")\n",
|
| 58 |
+
"df[\"timestamp\"] = pd.to_datetime(df[\"timestamp\"])\n",
|
| 59 |
+
"print(f\"Shape: {df.shape}\")\n",
|
| 60 |
+
"print(f\"Outage rate: {df.outage.mean():.3f}\")\n",
|
| 61 |
+
"print(f\"Mean duration (outage hours): {df[df.outage==1].duration_min.mean():.1f} min\")\n",
|
| 62 |
+
"df.describe()"
|
| 63 |
+
]
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"cell_type": "code",
|
| 67 |
+
"execution_count": null,
|
| 68 |
+
"id": "193bf41b",
|
| 69 |
+
"metadata": {},
|
| 70 |
+
"outputs": [],
|
| 71 |
+
"source": [
|
| 72 |
+
"fig, axes = plt.subplots(2, 2, figsize=(12, 6))\n",
|
| 73 |
+
"df.groupby(df.timestamp.dt.hour)[\"outage\"].mean().plot(ax=axes[0,0], title=\"Outage rate by hour\", color=\"#ef4444\")\n",
|
| 74 |
+
"df.groupby(df.timestamp.dt.dayofweek)[\"outage\"].mean().plot(ax=axes[0,1], title=\"Outage rate by weekday\", color=\"#f97316\")\n",
|
| 75 |
+
"df.groupby(df.timestamp.dt.month)[\"outage\"].mean().plot(ax=axes[1,0], title=\"Outage rate by month\", color=\"#6366f1\")\n",
|
| 76 |
+
"df[df.outage==1][\"duration_min\"].hist(ax=axes[1,1], bins=30, title=\"Duration distribution\", color=\"#22c55e\", edgecolor=\"black\")\n",
|
| 77 |
+
"plt.tight_layout()\n",
|
| 78 |
+
"plt.savefig(\"eda_plots.png\", dpi=80, bbox_inches=\"tight\")\n",
|
| 79 |
+
"plt.show()"
|
| 80 |
+
]
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"cell_type": "markdown",
|
| 84 |
+
"id": "bf4734e9",
|
| 85 |
+
"metadata": {},
|
| 86 |
+
"source": [
|
| 87 |
+
"## 2. Rolling 30-Day Evaluation"
|
| 88 |
+
]
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"cell_type": "code",
|
| 92 |
+
"execution_count": null,
|
| 93 |
+
"id": "769732fb",
|
| 94 |
+
"metadata": {},
|
| 95 |
+
"outputs": [],
|
| 96 |
+
"source": [
|
| 97 |
+
"metrics = rolling_eval(\"grid_history.csv\", window_days=30)\n",
|
| 98 |
+
"print(\"=\" * 40)\n",
|
| 99 |
+
"for k, v in metrics.items():\n",
|
| 100 |
+
" print(f\" {k}: {v}\")\n",
|
| 101 |
+
"print(\"=\" * 40)\n",
|
| 102 |
+
"\n",
|
| 103 |
+
"# Brier score interpretation\n",
|
| 104 |
+
"naive_rate = pd.read_csv(\"grid_history.csv\")[\"outage\"].mean()\n",
|
| 105 |
+
"naive_brier = naive_rate * (1 - naive_rate)\n",
|
| 106 |
+
"print(f\"\n",
|
| 107 |
+
"Naive Brier (always predict base rate {naive_rate:.3f}): {naive_brier:.4f}\")\n",
|
| 108 |
+
"print(f\"Model Brier: {metrics['brier_score']:.4f}\")\n",
|
| 109 |
+
"print(f\"Brier Skill Score: {1 - metrics['brier_score']/naive_brier:.3f} (higher = better)\")"
|
| 110 |
+
]
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"cell_type": "markdown",
|
| 114 |
+
"id": "b98f9689",
|
| 115 |
+
"metadata": {},
|
| 116 |
+
"source": [
|
| 117 |
+
"## 3. Forecast Visualization"
|
| 118 |
+
]
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
"cell_type": "code",
|
| 122 |
+
"execution_count": null,
|
| 123 |
+
"id": "c58bc81c",
|
| 124 |
+
"metadata": {},
|
| 125 |
+
"outputs": [],
|
| 126 |
+
"source": [
|
| 127 |
+
"fc = Forecaster().fit(\"grid_history.csv\")\n",
|
| 128 |
+
"forecast = fc.predict_next_24h()\n",
|
| 129 |
+
"\n",
|
| 130 |
+
"hours = [f[\"hour\"] for f in forecast]\n",
|
| 131 |
+
"probs = [f[\"p_outage\"] for f in forecast]\n",
|
| 132 |
+
"p_low = [f[\"p_outage_low\"] for f in forecast]\n",
|
| 133 |
+
"p_high = [f[\"p_outage_high\"] for f in forecast]\n",
|
| 134 |
+
"durations = [f[\"expected_duration_min\"] for f in forecast]\n",
|
| 135 |
+
"risks = [f[\"risk_level\"] for f in forecast]\n",
|
| 136 |
+
"\n",
|
| 137 |
+
"fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 7), sharex=True)\n",
|
| 138 |
+
"\n",
|
| 139 |
+
"# Colors by risk\n",
|
| 140 |
+
"cols = [\"#ef4444\" if r==\"HIGH\" else \"#f97316\" if r==\"MEDIUM\" else \"#22c55e\" for r in risks]\n",
|
| 141 |
+
"\n",
|
| 142 |
+
"ax1.fill_between(hours, p_low, p_high, alpha=0.25, color=\"#6366f1\", label=\"Uncertainty band\")\n",
|
| 143 |
+
"ax1.plot(hours, probs, \"o-\", color=\"#6366f1\", lw=2, ms=5, label=\"P(outage)\")\n",
|
| 144 |
+
"ax1.axhline(0.25, color=\"#ef4444\", ls=\"--\", lw=1, label=\"HIGH threshold\")\n",
|
| 145 |
+
"ax1.axhline(0.12, color=\"#f97316\", ls=\"--\", lw=1, label=\"MEDIUM threshold\")\n",
|
| 146 |
+
"ax1.set_ylabel(\"P(outage)\")\n",
|
| 147 |
+
"ax1.set_title(\"24-Hour Outage Forecast with Uncertainty Band\")\n",
|
| 148 |
+
"ax1.legend(fontsize=9)\n",
|
| 149 |
+
"ax1.set_ylim(0, 0.6)\n",
|
| 150 |
+
"\n",
|
| 151 |
+
"ax2.bar(hours, durations, color=cols, alpha=0.8, label=\"Expected duration (min)\")\n",
|
| 152 |
+
"ax2.set_xlabel(\"Hour of day\")\n",
|
| 153 |
+
"ax2.set_ylabel(\"E[duration | outage] (min)\")\n",
|
| 154 |
+
"ax2.set_title(\"Expected Outage Duration by Hour\")\n",
|
| 155 |
+
"ax2.set_xticks(hours)\n",
|
| 156 |
+
"\n",
|
| 157 |
+
"plt.tight_layout()\n",
|
| 158 |
+
"plt.savefig(\"forecast_plot.png\", dpi=80, bbox_inches=\"tight\")\n",
|
| 159 |
+
"plt.show()"
|
| 160 |
+
]
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
"cell_type": "markdown",
|
| 164 |
+
"id": "6b33fb28",
|
| 165 |
+
"metadata": {},
|
| 166 |
+
"source": [
|
| 167 |
+
"## 4. Appliance Plan β Salon Archetype"
|
| 168 |
+
]
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"cell_type": "code",
|
| 172 |
+
"execution_count": null,
|
| 173 |
+
"id": "51ceeb44",
|
| 174 |
+
"metadata": {},
|
| 175 |
+
"outputs": [],
|
| 176 |
+
"source": [
|
| 177 |
+
"appliances, businesses = load_data()\n",
|
| 178 |
+
"result = plan(forecast, appliances, \"salon\")\n",
|
| 179 |
+
"s = result[\"summary\"]\n",
|
| 180 |
+
"print(f\"Business: {result['business']}\")\n",
|
| 181 |
+
"print(f\"Net benefit vs naΓ―ve: {s['net_benefit_rwf']:,.0f} RWF\")\n",
|
| 182 |
+
"print(f\"Total plan revenue: {s['total_revenue_plan_rwf']:,.0f} RWF\")\n",
|
| 183 |
+
"print(f\"Disruption penalty avoided: {s['disruption_penalty_avoided_rwf']:,.0f} RWF\")\n",
|
| 184 |
+
"print(f\"Hours with shedding: {s['hours_with_shed']}/24\")\n",
|
| 185 |
+
"print()\n",
|
| 186 |
+
"\n",
|
| 187 |
+
"# Show plan table\n",
|
| 188 |
+
"rows = []\n",
|
| 189 |
+
"for h in result[\"plan\"]:\n",
|
| 190 |
+
" off = [a[\"name\"] for a in h[\"appliances\"] if a[\"state\"]==\"OFF\"]\n",
|
| 191 |
+
" rows.append({\"Hour\": h[\"hour\"], \"Time\": h[\"timestamp\"][11:], \"Risk\": h[\"risk_level\"],\n",
|
| 192 |
+
" \"P(out)\": f\"{h['p_outage']:.3f}\", \"OFF\": \", \".join(off) if off else \"β\"})\n",
|
| 193 |
+
"pd.DataFrame(rows).to_string(index=False) "
|
| 194 |
+
]
|
| 195 |
+
},
|
| 196 |
+
{
|
| 197 |
+
"cell_type": "markdown",
|
| 198 |
+
"id": "1b65b920",
|
| 199 |
+
"metadata": {},
|
| 200 |
+
"source": [
|
| 201 |
+
"## 5. SMS Digest"
|
| 202 |
+
]
|
| 203 |
+
},
|
| 204 |
+
{
|
| 205 |
+
"cell_type": "code",
|
| 206 |
+
"execution_count": null,
|
| 207 |
+
"id": "21e98b96",
|
| 208 |
+
"metadata": {},
|
| 209 |
+
"outputs": [],
|
| 210 |
+
"source": [
|
| 211 |
+
"sms = format_digest(result, forecast)\n",
|
| 212 |
+
"for i, msg in enumerate(sms, 1):\n",
|
| 213 |
+
" print(f\"SMS {i}/3 ({len(msg)} chars):\")\n",
|
| 214 |
+
" print(msg)\n",
|
| 215 |
+
" print()"
|
| 216 |
+
]
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
"cell_type": "markdown",
|
| 220 |
+
"id": "f3e987ce",
|
| 221 |
+
"metadata": {},
|
| 222 |
+
"source": [
|
| 223 |
+
"## 6. Feature Importance"
|
| 224 |
+
]
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
"cell_type": "code",
|
| 228 |
+
"execution_count": null,
|
| 229 |
+
"id": "8d9de197",
|
| 230 |
+
"metadata": {},
|
| 231 |
+
"outputs": [],
|
| 232 |
+
"source": [
|
| 233 |
+
"import pandas as pd\n",
|
| 234 |
+
"df_feat = build_features(pd.read_csv(\"grid_history.csv\"))\n",
|
| 235 |
+
"fc2 = Forecaster().fit(\"grid_history.csv\")\n",
|
| 236 |
+
"\n",
|
| 237 |
+
"fimp = pd.Series(fc2.clf.feature_importances_, index=FEATURE_COLS).sort_values(ascending=False)\n",
|
| 238 |
+
"fig, ax = plt.subplots(figsize=(8, 5))\n",
|
| 239 |
+
"fimp.plot(kind=\"barh\", ax=ax, color=\"#6366f1\")\n",
|
| 240 |
+
"ax.set_title(\"LightGBM Feature Importances β Outage Classifier\")\n",
|
| 241 |
+
"ax.set_xlabel(\"Importance\")\n",
|
| 242 |
+
"plt.tight_layout()\n",
|
| 243 |
+
"plt.savefig(\"feature_importance.png\", dpi=80, bbox_inches=\"tight\")\n",
|
| 244 |
+
"plt.show()\n",
|
| 245 |
+
"print(fimp.to_string())"
|
| 246 |
+
]
|
| 247 |
+
}
|
| 248 |
+
],
|
| 249 |
+
"metadata": {},
|
| 250 |
+
"nbformat": 4,
|
| 251 |
+
"nbformat_minor": 5
|
| 252 |
+
}
|
src/feature_importance.png
ADDED
|
src/forecast_plot.png
ADDED
|
src/forecaster.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
T2.3 Β· forecaster.py
|
| 3 |
+
24-hour-ahead probabilistic outage forecaster.
|
| 4 |
+
Outputs P(outage) and E[duration | outage] per hour.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
from forecaster import Forecaster
|
| 8 |
+
fc = Forecaster()
|
| 9 |
+
fc.fit("grid_history.csv")
|
| 10 |
+
forecast = fc.predict_next_24h() # list of 24 dicts
|
| 11 |
+
|
| 12 |
+
API endpoint (fast path):
|
| 13 |
+
python forecaster.py --serve # prints JSON, <300ms on CPU
|
| 14 |
+
python forecaster.py --eval # rolling 30-day Brier + MAE
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import json
|
| 19 |
+
import time
|
| 20 |
+
import warnings
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import pandas as pd
|
| 25 |
+
from lightgbm import LGBMClassifier, LGBMRegressor
|
| 26 |
+
|
| 27 |
+
warnings.filterwarnings("ignore")
|
| 28 |
+
|
| 29 |
+
MODEL_PATH_CLF = "model_outage_clf.pkl"
|
| 30 |
+
MODEL_PATH_REG = "model_duration_reg.pkl"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ββ Feature Engineering βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
|
| 35 |
+
def build_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 36 |
+
df = df.copy()
|
| 37 |
+
df["timestamp"] = pd.to_datetime(df["timestamp"])
|
| 38 |
+
df = df.sort_values("timestamp").reset_index(drop=True)
|
| 39 |
+
|
| 40 |
+
df["hour"] = df["timestamp"].dt.hour
|
| 41 |
+
df["dayofweek"] = df["timestamp"].dt.dayofweek
|
| 42 |
+
df["month"] = df["timestamp"].dt.month
|
| 43 |
+
df["is_weekend"] = (df["dayofweek"] >= 5).astype(int)
|
| 44 |
+
df["is_peak_morning"] = ((df["hour"] >= 7) & (df["hour"] <= 10)).astype(int)
|
| 45 |
+
df["is_peak_evening"] = ((df["hour"] >= 17) & (df["hour"] <= 21)).astype(int)
|
| 46 |
+
df["is_rainy_season"] = df["month"].isin([4, 5, 10, 11]).astype(int)
|
| 47 |
+
|
| 48 |
+
# Lagged load features (1h, 2h, 24h, 48h)
|
| 49 |
+
for lag in [1, 2, 24, 48]:
|
| 50 |
+
df[f"load_lag{lag}"] = df["load_mw"].shift(lag)
|
| 51 |
+
|
| 52 |
+
# Rolling stats
|
| 53 |
+
df["load_roll3_mean"] = df["load_mw"].shift(1).rolling(3).mean()
|
| 54 |
+
df["load_roll6_std"] = df["load_mw"].shift(1).rolling(6).std()
|
| 55 |
+
df["rain_roll3_sum"] = df["rain_mm"].shift(1).rolling(3).sum()
|
| 56 |
+
df["outage_lag1"] = df["outage"].shift(1)
|
| 57 |
+
df["outage_roll6_sum"] = df["outage"].shift(1).rolling(6).sum()
|
| 58 |
+
|
| 59 |
+
df = df.dropna().reset_index(drop=True)
|
| 60 |
+
return df
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
FEATURE_COLS = [
|
| 64 |
+
"load_lag1", "load_lag2", "load_lag24", "load_lag48",
|
| 65 |
+
"load_roll3_mean", "load_roll6_std", "rain_roll3_sum",
|
| 66 |
+
"temp_c", "humidity", "wind_ms", "rain_mm",
|
| 67 |
+
"hour", "dayofweek", "month", "is_weekend",
|
| 68 |
+
"is_peak_morning", "is_peak_evening", "is_rainy_season",
|
| 69 |
+
"outage_lag1", "outage_roll6_sum",
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ββ Forecaster Class ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
+
|
| 75 |
+
class Forecaster:
|
| 76 |
+
def __init__(self):
|
| 77 |
+
self.clf = LGBMClassifier(
|
| 78 |
+
n_estimators=200,
|
| 79 |
+
learning_rate=0.05,
|
| 80 |
+
max_depth=5,
|
| 81 |
+
num_leaves=31,
|
| 82 |
+
class_weight="balanced",
|
| 83 |
+
random_state=42,
|
| 84 |
+
verbose=-1,
|
| 85 |
+
)
|
| 86 |
+
self.reg = LGBMRegressor(
|
| 87 |
+
n_estimators=200,
|
| 88 |
+
learning_rate=0.05,
|
| 89 |
+
max_depth=5,
|
| 90 |
+
num_leaves=31,
|
| 91 |
+
random_state=42,
|
| 92 |
+
verbose=-1,
|
| 93 |
+
)
|
| 94 |
+
self.df_features = None
|
| 95 |
+
self.is_fitted = False
|
| 96 |
+
|
| 97 |
+
def fit(self, csv_path: str = "grid_history.csv"):
|
| 98 |
+
df_raw = pd.read_csv(csv_path)
|
| 99 |
+
df = build_features(df_raw)
|
| 100 |
+
self.df_features = df # store for forecasting context
|
| 101 |
+
|
| 102 |
+
X = df[FEATURE_COLS]
|
| 103 |
+
y_clf = df["outage"]
|
| 104 |
+
y_reg = df.loc[df["outage"] == 1, "duration_min"]
|
| 105 |
+
X_reg = df.loc[df["outage"] == 1, FEATURE_COLS]
|
| 106 |
+
|
| 107 |
+
self.clf.fit(X, y_clf)
|
| 108 |
+
self.reg.fit(X_reg, y_reg)
|
| 109 |
+
self.is_fitted = True
|
| 110 |
+
print(f"β Forecaster fitted on {len(df)} rows")
|
| 111 |
+
return self
|
| 112 |
+
|
| 113 |
+
def predict_next_24h(self, reference_time=None) -> list[dict]:
|
| 114 |
+
"""
|
| 115 |
+
Build a 24-hour ahead forecast from the last known data point.
|
| 116 |
+
Returns list of 24 dicts: {hour, timestamp, p_outage, expected_duration_min, risk_level}
|
| 117 |
+
"""
|
| 118 |
+
if not self.is_fitted:
|
| 119 |
+
raise RuntimeError("Call fit() first.")
|
| 120 |
+
|
| 121 |
+
df = self.df_features
|
| 122 |
+
# Use last row as context anchor
|
| 123 |
+
last = df.iloc[-1]
|
| 124 |
+
if reference_time is None:
|
| 125 |
+
reference_time = pd.to_datetime(last["timestamp"]) + pd.Timedelta(hours=1)
|
| 126 |
+
|
| 127 |
+
forecast = []
|
| 128 |
+
# We'll use the last known feature values and adjust hour/temporal features
|
| 129 |
+
for offset in range(24):
|
| 130 |
+
ts = reference_time + pd.Timedelta(hours=offset)
|
| 131 |
+
h = ts.hour
|
| 132 |
+
dow = ts.dayofweek
|
| 133 |
+
month = ts.month
|
| 134 |
+
|
| 135 |
+
# Build feature row (use last context for lagged values; simplified for inference)
|
| 136 |
+
row = {
|
| 137 |
+
"load_lag1": last["load_mw"],
|
| 138 |
+
"load_lag2": df.iloc[-2]["load_mw"] if len(df) > 2 else last["load_mw"],
|
| 139 |
+
"load_lag24": df.iloc[-24]["load_mw"] if len(df) >= 24 else last["load_mw"],
|
| 140 |
+
"load_lag48": df.iloc[-48]["load_mw"] if len(df) >= 48 else last["load_mw"],
|
| 141 |
+
"load_roll3_mean": df["load_mw"].iloc[-3:].mean(),
|
| 142 |
+
"load_roll6_std": df["load_mw"].iloc[-6:].std(),
|
| 143 |
+
"rain_roll3_sum": df["rain_mm"].iloc[-3:].sum(),
|
| 144 |
+
"temp_c": last["temp_c"] + 2 * np.sin(2 * np.pi * (h - 14) / 24),
|
| 145 |
+
"humidity": float(np.clip(last["humidity"] + np.random.normal(0, 2), 30, 99)),
|
| 146 |
+
"wind_ms": max(0, float(last["wind_ms"])),
|
| 147 |
+
"rain_mm": float(last["rain_mm"] * 0.7), # decay
|
| 148 |
+
"hour": h,
|
| 149 |
+
"dayofweek": dow,
|
| 150 |
+
"month": month,
|
| 151 |
+
"is_weekend": int(dow >= 5),
|
| 152 |
+
"is_peak_morning": int(7 <= h <= 10),
|
| 153 |
+
"is_peak_evening": int(17 <= h <= 21),
|
| 154 |
+
"is_rainy_season": int(month in [4, 5, 10, 11]),
|
| 155 |
+
"outage_lag1": int(last["outage"]),
|
| 156 |
+
"outage_roll6_sum": float(df["outage"].iloc[-6:].sum()),
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
X_row = pd.DataFrame([row])[FEATURE_COLS]
|
| 160 |
+
p_out = float(self.clf.predict_proba(X_row)[0, 1])
|
| 161 |
+
exp_dur = float(self.reg.predict(X_row)[0]) if p_out > 0.05 else 0.0
|
| 162 |
+
exp_dur = max(0, exp_dur)
|
| 163 |
+
|
| 164 |
+
# Add calibrated uncertainty band (Β±1 sigma heuristic)
|
| 165 |
+
p_low = max(0.0, p_out - 0.08)
|
| 166 |
+
p_high = min(1.0, p_out + 0.08)
|
| 167 |
+
|
| 168 |
+
risk = "HIGH" if p_out >= 0.25 else "MEDIUM" if p_out >= 0.12 else "LOW"
|
| 169 |
+
|
| 170 |
+
forecast.append({
|
| 171 |
+
"hour_offset": offset,
|
| 172 |
+
"timestamp": ts.strftime("%Y-%m-%d %H:%M"),
|
| 173 |
+
"hour": h,
|
| 174 |
+
"p_outage": round(p_out, 4),
|
| 175 |
+
"p_outage_low": round(p_low, 4),
|
| 176 |
+
"p_outage_high": round(p_high, 4),
|
| 177 |
+
"expected_duration_min": round(exp_dur, 1),
|
| 178 |
+
"risk_level": risk,
|
| 179 |
+
})
|
| 180 |
+
|
| 181 |
+
return forecast
|
| 182 |
+
|
| 183 |
+
def save(self):
|
| 184 |
+
import pickle
|
| 185 |
+
with open(MODEL_PATH_CLF, "wb") as f:
|
| 186 |
+
pickle.dump(self.clf, f)
|
| 187 |
+
with open(MODEL_PATH_REG, "wb") as f:
|
| 188 |
+
pickle.dump(self.reg, f)
|
| 189 |
+
print(f"β Models saved: {MODEL_PATH_CLF}, {MODEL_PATH_REG}")
|
| 190 |
+
|
| 191 |
+
@classmethod
|
| 192 |
+
def load(cls):
|
| 193 |
+
import pickle
|
| 194 |
+
fc = cls()
|
| 195 |
+
with open(MODEL_PATH_CLF, "rb") as f:
|
| 196 |
+
fc.clf = pickle.load(f)
|
| 197 |
+
with open(MODEL_PATH_REG, "rb") as f:
|
| 198 |
+
fc.reg = pickle.load(f)
|
| 199 |
+
# Need df_features for inference context; rebuild from CSV
|
| 200 |
+
if Path("grid_history.csv").exists():
|
| 201 |
+
df_raw = pd.read_csv("grid_history.csv")
|
| 202 |
+
fc.df_features = build_features(df_raw)
|
| 203 |
+
fc.is_fitted = True
|
| 204 |
+
return fc
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# ββ Rolling Evaluation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 208 |
+
|
| 209 |
+
def rolling_eval(csv_path: str = "grid_history.csv", window_days: int = 30):
|
| 210 |
+
"""
|
| 211 |
+
Rolling 30-day held-out evaluation.
|
| 212 |
+
Returns: brier_score, mae_duration, avg_lead_time_hours
|
| 213 |
+
"""
|
| 214 |
+
df_raw = pd.read_csv(csv_path)
|
| 215 |
+
df = build_features(df_raw)
|
| 216 |
+
|
| 217 |
+
# Use last 30 days as test, rest as train
|
| 218 |
+
test_cutoff = df["timestamp"].max() - pd.Timedelta(days=window_days)
|
| 219 |
+
df_train = df[df["timestamp"] <= test_cutoff]
|
| 220 |
+
df_test = df[df["timestamp"] > test_cutoff]
|
| 221 |
+
|
| 222 |
+
X_train = df_train[FEATURE_COLS]
|
| 223 |
+
y_train = df_train["outage"]
|
| 224 |
+
X_test = df_test[FEATURE_COLS]
|
| 225 |
+
y_test = df_test["outage"]
|
| 226 |
+
|
| 227 |
+
clf = LGBMClassifier(n_estimators=200, learning_rate=0.05, max_depth=5,
|
| 228 |
+
class_weight="balanced", random_state=42, verbose=-1)
|
| 229 |
+
clf.fit(X_train, y_train)
|
| 230 |
+
probs = clf.predict_proba(X_test)[:, 1]
|
| 231 |
+
|
| 232 |
+
# Brier score
|
| 233 |
+
brier = float(np.mean((probs - y_test.values) ** 2))
|
| 234 |
+
|
| 235 |
+
# Duration MAE (on true outage hours)
|
| 236 |
+
df_train_out = df_train[df_train["outage"] == 1]
|
| 237 |
+
df_test_out = df_test[df_test["outage"] == 1]
|
| 238 |
+
mae_dur = None
|
| 239 |
+
if len(df_train_out) > 5 and len(df_test_out) > 0:
|
| 240 |
+
reg = LGBMRegressor(n_estimators=200, random_state=42, verbose=-1)
|
| 241 |
+
reg.fit(df_train_out[FEATURE_COLS], df_train_out["duration_min"])
|
| 242 |
+
preds_dur = reg.predict(df_test_out[FEATURE_COLS])
|
| 243 |
+
mae_dur = float(np.mean(np.abs(preds_dur - df_test_out["duration_min"].values)))
|
| 244 |
+
|
| 245 |
+
# Lead time: for each true outage, find if model flagged it β₯1h before
|
| 246 |
+
df_test2 = df_test.copy()
|
| 247 |
+
df_test2["pred_prob"] = probs
|
| 248 |
+
df_test2["flagged"] = (probs >= 0.15).astype(int)
|
| 249 |
+
outage_hours = df_test2[df_test2["outage"] == 1].index
|
| 250 |
+
lead_times = []
|
| 251 |
+
for idx in outage_hours:
|
| 252 |
+
# look back up to 3 rows
|
| 253 |
+
look_back = df_test2.loc[max(df_test2.index[0], idx-3):idx-1]
|
| 254 |
+
if len(look_back) > 0 and look_back["flagged"].any():
|
| 255 |
+
lead_times.append(look_back["flagged"].sum())
|
| 256 |
+
avg_lead = float(np.mean(lead_times)) if lead_times else 0.0
|
| 257 |
+
|
| 258 |
+
return {
|
| 259 |
+
"brier_score": round(brier, 4),
|
| 260 |
+
"mae_duration_min": round(mae_dur, 1) if mae_dur else None,
|
| 261 |
+
"avg_lead_time_hours": round(avg_lead, 2),
|
| 262 |
+
"n_test_hours": len(df_test),
|
| 263 |
+
"n_test_outages": int(y_test.sum()),
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# ββ CLI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 268 |
+
|
| 269 |
+
if __name__ == "__main__":
|
| 270 |
+
parser = argparse.ArgumentParser()
|
| 271 |
+
parser.add_argument("--serve", action="store_true", help="Print 24h forecast JSON")
|
| 272 |
+
parser.add_argument("--eval", action="store_true", help="Run rolling evaluation")
|
| 273 |
+
parser.add_argument("--fit", action="store_true", help="Fit and save model")
|
| 274 |
+
args = parser.parse_args()
|
| 275 |
+
|
| 276 |
+
if args.eval:
|
| 277 |
+
print("Running rolling 30-day evaluation...")
|
| 278 |
+
metrics = rolling_eval()
|
| 279 |
+
print(json.dumps(metrics, indent=2))
|
| 280 |
+
|
| 281 |
+
elif args.serve:
|
| 282 |
+
t0 = time.time()
|
| 283 |
+
fc = Forecaster().fit("grid_history.csv")
|
| 284 |
+
forecast = fc.predict_next_24h()
|
| 285 |
+
elapsed_ms = (time.time() - t0) * 1000
|
| 286 |
+
output = {"forecast": forecast, "generated_at": pd.Timestamp.now().isoformat(),
|
| 287 |
+
"latency_ms": round(elapsed_ms, 1)}
|
| 288 |
+
print(json.dumps(output, indent=2))
|
| 289 |
+
print(f"\nβ± Total latency: {elapsed_ms:.0f}ms", flush=True)
|
| 290 |
+
|
| 291 |
+
elif args.fit:
|
| 292 |
+
fc = Forecaster().fit("grid_history.csv")
|
| 293 |
+
fc.save()
|
| 294 |
+
|
| 295 |
+
else:
|
| 296 |
+
# Default: fit + quick forecast preview
|
| 297 |
+
fc = Forecaster().fit("grid_history.csv")
|
| 298 |
+
forecast = fc.predict_next_24h()
|
| 299 |
+
print("\n24-Hour Forecast Preview:")
|
| 300 |
+
print(f"{'Hour':>5} {'Time':>15} {'P(outage)':>10} {'ExpDur(min)':>12} {'Risk':>8}")
|
| 301 |
+
print("-" * 55)
|
| 302 |
+
for row in forecast:
|
| 303 |
+
print(f"{row['hour']:>5} {row['timestamp']:>15} {row['p_outage']:>10.3f} "
|
| 304 |
+
f"{row['expected_duration_min']:>12.0f} {row['risk_level']:>8}")
|
src/generate_data.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
T2.3 Β· Grid Outage Forecaster + Appliance Prioritizer
|
| 3 |
+
Data Generator β reproducible synthetic dataset
|
| 4 |
+
Run: python generate_data.py
|
| 5 |
+
Outputs: grid_history.csv, appliances.json, businesses.json
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import json
|
| 11 |
+
from datetime import datetime, timedelta
|
| 12 |
+
|
| 13 |
+
SEED = 42
|
| 14 |
+
np.random.seed(SEED)
|
| 15 |
+
|
| 16 |
+
# ββ 1. GRID HISTORY ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
|
| 18 |
+
def sigmoid(x):
|
| 19 |
+
return 1 / (1 + np.exp(-x))
|
| 20 |
+
|
| 21 |
+
def generate_grid_history(days=180, seed=SEED):
|
| 22 |
+
np.random.seed(seed)
|
| 23 |
+
start = datetime(2024, 1, 1, 0, 0)
|
| 24 |
+
records = []
|
| 25 |
+
|
| 26 |
+
for d in range(days):
|
| 27 |
+
date = start + timedelta(days=d)
|
| 28 |
+
week = d // 7
|
| 29 |
+
# Rainy season: Apr-May, Oct-Nov (months 4,5,10,11)
|
| 30 |
+
month = date.month
|
| 31 |
+
rainy = month in [4, 5, 10, 11]
|
| 32 |
+
|
| 33 |
+
for h in range(24):
|
| 34 |
+
ts = date + timedelta(hours=h)
|
| 35 |
+
|
| 36 |
+
# Load: two peaks (morning ~8, evening ~19), weekly seasonality
|
| 37 |
+
morning_peak = 80 * np.exp(-0.5 * ((h - 8) / 2.5) ** 2)
|
| 38 |
+
evening_peak = 100 * np.exp(-0.5 * ((h - 19) / 2.0) ** 2)
|
| 39 |
+
base_load = 40
|
| 40 |
+
weekday_boost = 15 if date.weekday() < 5 else -10
|
| 41 |
+
rainy_noise = np.random.normal(0, 12 if rainy else 4)
|
| 42 |
+
load_mw = max(10, base_load + morning_peak + evening_peak +
|
| 43 |
+
weekday_boost + rainy_noise)
|
| 44 |
+
|
| 45 |
+
# Weather
|
| 46 |
+
temp_c = 22 + 6 * np.sin(2 * np.pi * (h - 14) / 24) + \
|
| 47 |
+
np.random.normal(0, 1.5) + (3 if rainy else 0)
|
| 48 |
+
humidity = 60 + (20 if rainy else 0) + 10 * np.sin(2 * np.pi * h / 24) + \
|
| 49 |
+
np.random.normal(0, 5)
|
| 50 |
+
humidity = np.clip(humidity, 30, 99)
|
| 51 |
+
wind_ms = max(0, np.random.exponential(3) + (2 if rainy else 0))
|
| 52 |
+
rain_mm = np.random.exponential(3) if (rainy and np.random.rand() < 0.4) else 0.0
|
| 53 |
+
|
| 54 |
+
# Outage probability: logistic model
|
| 55 |
+
load_lag1 = load_mw * (1 + np.random.normal(0, 0.02)) # approx lag
|
| 56 |
+
a0, a1, a2, a3 = -3.5, 0.015, 0.08, 0.04
|
| 57 |
+
log_odds = a0 + a1 * load_lag1 + a2 * rain_mm + a3 * (1 if h in range(7, 22) else 0)
|
| 58 |
+
p_outage = sigmoid(log_odds)
|
| 59 |
+
p_outage = np.clip(p_outage + (0.02 if rainy else 0), 0.01, 0.35)
|
| 60 |
+
outage = int(np.random.rand() < p_outage)
|
| 61 |
+
|
| 62 |
+
# Duration: LogNormal if outage
|
| 63 |
+
duration_min = 0
|
| 64 |
+
if outage:
|
| 65 |
+
duration_min = int(np.random.lognormal(mean=np.log(90), sigma=0.6))
|
| 66 |
+
duration_min = max(5, min(duration_min, 480))
|
| 67 |
+
|
| 68 |
+
records.append({
|
| 69 |
+
"timestamp": ts.strftime("%Y-%m-%d %H:%M:%S"),
|
| 70 |
+
"load_mw": round(load_mw, 2),
|
| 71 |
+
"temp_c": round(temp_c, 2),
|
| 72 |
+
"humidity": round(humidity, 2),
|
| 73 |
+
"wind_ms": round(wind_ms, 2),
|
| 74 |
+
"rain_mm": round(rain_mm, 2),
|
| 75 |
+
"outage": outage,
|
| 76 |
+
"duration_min": duration_min,
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
df = pd.DataFrame(records)
|
| 80 |
+
df.to_csv("grid_history.csv", index=False)
|
| 81 |
+
print(f"β grid_history.csv {len(df)} rows outage_rate={df.outage.mean():.3f}")
|
| 82 |
+
return df
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ββ 2. APPLIANCES βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
+
|
| 87 |
+
APPLIANCES = [
|
| 88 |
+
{"id": "fridge", "name": "Commercial Refrigerator", "category": "critical",
|
| 89 |
+
"watts_avg": 350, "start_up_spike_w": 700, "revenue_if_running_rwf_per_h": 2500},
|
| 90 |
+
{"id": "hair_dryer", "name": "Hair Dryer (2Γ)", "category": "critical",
|
| 91 |
+
"watts_avg": 2400, "start_up_spike_w": 2500, "revenue_if_running_rwf_per_h": 3000},
|
| 92 |
+
{"id": "clippers", "name": "Electric Clippers (3Γ)", "category": "critical",
|
| 93 |
+
"watts_avg": 120, "start_up_spike_w": 150, "revenue_if_running_rwf_per_h": 2000},
|
| 94 |
+
{"id": "water_pump", "name": "Water Pump", "category": "critical",
|
| 95 |
+
"watts_avg": 750, "start_up_spike_w": 1500, "revenue_if_running_rwf_per_h": 1500},
|
| 96 |
+
{"id": "lights", "name": "LED Lights", "category": "critical",
|
| 97 |
+
"watts_avg": 80, "start_up_spike_w": 80, "revenue_if_running_rwf_per_h": 1000},
|
| 98 |
+
{"id": "air_con", "name": "Air Conditioner", "category": "comfort",
|
| 99 |
+
"watts_avg": 1500, "start_up_spike_w": 3000, "revenue_if_running_rwf_per_h": 800},
|
| 100 |
+
{"id": "fan", "name": "Standing Fan", "category": "comfort",
|
| 101 |
+
"watts_avg": 75, "start_up_spike_w": 80, "revenue_if_running_rwf_per_h": 400},
|
| 102 |
+
{"id": "tv", "name": "TV / Display Screen", "category": "comfort",
|
| 103 |
+
"watts_avg": 150, "start_up_spike_w": 160, "revenue_if_running_rwf_per_h": 200},
|
| 104 |
+
{"id": "music", "name": "Music System", "category": "luxury",
|
| 105 |
+
"watts_avg": 200, "start_up_spike_w": 220, "revenue_if_running_rwf_per_h": 100},
|
| 106 |
+
{"id": "neon_sign", "name": "Neon Sign", "category": "luxury",
|
| 107 |
+
"watts_avg": 60, "start_up_spike_w": 65, "revenue_if_running_rwf_per_h": 50},
|
| 108 |
+
]
|
| 109 |
+
|
| 110 |
+
# ββ 3. BUSINESSES βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 111 |
+
|
| 112 |
+
BUSINESSES = [
|
| 113 |
+
{
|
| 114 |
+
"id": "salon",
|
| 115 |
+
"name": "Beauty Salon (Kigali)",
|
| 116 |
+
"archetype": "salon",
|
| 117 |
+
"description": "4-chair salon, open 07:00β20:00, 6 days/week",
|
| 118 |
+
"generator_kva": 2.0,
|
| 119 |
+
"appliance_ids": ["hair_dryer", "clippers", "lights", "fan", "tv", "music", "neon_sign"],
|
| 120 |
+
"peak_hours": [8, 9, 10, 15, 16, 17, 18],
|
| 121 |
+
"monthly_revenue_rwf": 1_800_000,
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"id": "cold_room",
|
| 125 |
+
"name": "Cold Room / Butchery",
|
| 126 |
+
"archetype": "cold_room",
|
| 127 |
+
"description": "Meat storage + retail, 05:00β22:00, 7 days",
|
| 128 |
+
"generator_kva": 3.5,
|
| 129 |
+
"appliance_ids": ["fridge", "lights", "water_pump", "fan", "tv"],
|
| 130 |
+
"peak_hours": [5, 6, 7, 17, 18, 19, 20],
|
| 131 |
+
"monthly_revenue_rwf": 2_500_000,
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"id": "tailor",
|
| 135 |
+
"name": "Tailor Shop",
|
| 136 |
+
"archetype": "tailor",
|
| 137 |
+
"description": "3 sewing machines + ironing, 08:00β18:00, 6 days",
|
| 138 |
+
"generator_kva": 1.5,
|
| 139 |
+
"appliance_ids": ["lights", "fan", "music", "tv"],
|
| 140 |
+
"peak_hours": [9, 10, 11, 14, 15, 16],
|
| 141 |
+
"monthly_revenue_rwf": 900_000,
|
| 142 |
+
},
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def generate_appliance_files():
|
| 147 |
+
with open("appliances.json", "w") as f:
|
| 148 |
+
json.dump(APPLIANCES, f, indent=2)
|
| 149 |
+
print(f"β appliances.json {len(APPLIANCES)} appliances")
|
| 150 |
+
|
| 151 |
+
with open("businesses.json", "w") as f:
|
| 152 |
+
json.dump(BUSINESSES, f, indent=2)
|
| 153 |
+
print(f"β businesses.json {len(BUSINESSES)} businesses")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
if __name__ == "__main__":
|
| 157 |
+
generate_grid_history()
|
| 158 |
+
generate_appliance_files()
|
| 159 |
+
print("\nAll data files generated successfully.")
|
src/grid_history.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/lite_ui.html
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>T2.3 Β· Grid Outage Forecaster + Appliance Prioritizer</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #0f1117; --surface: #1a1d27; --surface2: #22263a;
|
| 10 |
+
--border: #2e3350; --text: #e8eaf6; --muted: #8892b0;
|
| 11 |
+
--red: #ef4444; --orange: #f97316; --yellow: #eab308;
|
| 12 |
+
--green: #22c55e; --blue: #3b82f6; --purple: #a855f7;
|
| 13 |
+
--accent: #6366f1;
|
| 14 |
+
}
|
| 15 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 16 |
+
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif;
|
| 17 |
+
font-size: 14px; line-height: 1.5; }
|
| 18 |
+
.container { max-width: 1100px; margin: 0 auto; padding: 16px; }
|
| 19 |
+
h1 { font-size: 1.3rem; font-weight: 700; color: var(--accent); }
|
| 20 |
+
h2 { font-size: 1rem; font-weight: 600; margin-bottom: 10px; color: var(--text); }
|
| 21 |
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px;
|
| 22 |
+
font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
| 23 |
+
.badge-high { background: #7f1d1d; color: #fca5a5; }
|
| 24 |
+
.badge-medium { background: #78350f; color: #fcd34d; }
|
| 25 |
+
.badge-low { background: #14532d; color: #86efac; }
|
| 26 |
+
.badge-on { background: #14532d; color: #86efac; }
|
| 27 |
+
.badge-off { background: #3f3f46; color: #a1a1aa; }
|
| 28 |
+
.badge-critical { background: #1e3a8a; color: #93c5fd; }
|
| 29 |
+
.badge-comfort { background: #4a1d96; color: #c4b5fd; }
|
| 30 |
+
.badge-luxury { background: #374151; color: #9ca3af; }
|
| 31 |
+
|
| 32 |
+
.header { display: flex; align-items: center; justify-content: space-between;
|
| 33 |
+
padding: 12px 16px; background: var(--surface); border-radius: 10px;
|
| 34 |
+
border: 1px solid var(--border); margin-bottom: 14px; }
|
| 35 |
+
.header-meta { display: flex; gap: 20px; align-items: center; }
|
| 36 |
+
.metric { text-align: center; }
|
| 37 |
+
.metric-val { font-size: 1.4rem; font-weight: 800; color: var(--accent); }
|
| 38 |
+
.metric-lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; }
|
| 39 |
+
|
| 40 |
+
.tabs { display: flex; gap: 4px; margin-bottom: 14px; }
|
| 41 |
+
.tab { padding: 7px 16px; border-radius: 6px; border: 1px solid var(--border);
|
| 42 |
+
background: var(--surface); cursor: pointer; font-size: 13px; color: var(--muted);
|
| 43 |
+
transition: all .15s; }
|
| 44 |
+
.tab.active { background: var(--accent); color: white; border-color: var(--accent); }
|
| 45 |
+
|
| 46 |
+
.panel { display: none; }
|
| 47 |
+
.panel.active { display: block; }
|
| 48 |
+
|
| 49 |
+
/* Chart */
|
| 50 |
+
.chart-wrap { position: relative; background: var(--surface); border: 1px solid var(--border);
|
| 51 |
+
border-radius: 10px; padding: 16px; margin-bottom: 14px; }
|
| 52 |
+
canvas { width: 100% !important; }
|
| 53 |
+
.chart-legend { display: flex; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; }
|
| 54 |
+
.legend-item { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); }
|
| 55 |
+
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
| 56 |
+
|
| 57 |
+
/* Hour grid */
|
| 58 |
+
.hour-grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; margin-bottom: 14px; }
|
| 59 |
+
.hour-cell { background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
| 60 |
+
padding: 6px 4px; text-align: center; cursor: pointer; transition: all .15s; }
|
| 61 |
+
.hour-cell:hover { border-color: var(--accent); }
|
| 62 |
+
.hour-cell.selected { border-color: var(--accent); background: var(--surface2); }
|
| 63 |
+
.hour-cell .hc-hour { font-size: 11px; color: var(--muted); }
|
| 64 |
+
.hour-cell .hc-prob { font-size: 13px; font-weight: 700; }
|
| 65 |
+
.hc-high { color: var(--red); }
|
| 66 |
+
.hc-medium { color: var(--orange); }
|
| 67 |
+
.hc-low { color: var(--green); }
|
| 68 |
+
|
| 69 |
+
/* Appliance table */
|
| 70 |
+
.ap-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 14px; }
|
| 71 |
+
@media (max-width: 600px) { .ap-grid { grid-template-columns: 1fr; } }
|
| 72 |
+
.ap-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
| 73 |
+
padding: 10px 12px; display: flex; align-items: center; justify-content: space-between; }
|
| 74 |
+
.ap-card.off { opacity: .65; border-color: #3f3f46; }
|
| 75 |
+
.ap-left { display: flex; flex-direction: column; gap: 3px; }
|
| 76 |
+
.ap-name { font-weight: 600; font-size: 13px; }
|
| 77 |
+
.ap-meta { display: flex; gap: 6px; }
|
| 78 |
+
.ap-right { text-align: right; }
|
| 79 |
+
.ap-watts { font-size: 11px; color: var(--muted); }
|
| 80 |
+
.ap-rev { font-size: 12px; color: var(--green); font-weight: 600; }
|
| 81 |
+
|
| 82 |
+
/* SMS */
|
| 83 |
+
.sms-box { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
|
| 84 |
+
padding: 12px 14px; margin-bottom: 10px; }
|
| 85 |
+
.sms-header { display: flex; justify-content: space-between; align-items: center;
|
| 86 |
+
margin-bottom: 6px; }
|
| 87 |
+
.sms-num { font-size: 11px; font-weight: 700; color: var(--accent); }
|
| 88 |
+
.sms-chars { font-size: 10px; color: var(--muted); }
|
| 89 |
+
.sms-text { font-family: monospace; font-size: 13px; color: var(--text); line-height: 1.6;
|
| 90 |
+
word-break: break-word; }
|
| 91 |
+
|
| 92 |
+
/* Summary bar */
|
| 93 |
+
.summary-bar { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
|
| 94 |
+
margin-bottom: 14px; }
|
| 95 |
+
@media (max-width: 600px) { .summary-bar { grid-template-columns: repeat(2, 1fr); } }
|
| 96 |
+
.sum-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
| 97 |
+
padding: 10px 12px; text-align: center; }
|
| 98 |
+
.sum-val { font-size: 1.1rem; font-weight: 800; }
|
| 99 |
+
.sum-lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; margin-top: 2px; }
|
| 100 |
+
.text-green { color: var(--green); }
|
| 101 |
+
.text-orange { color: var(--orange); }
|
| 102 |
+
.text-blue { color: var(--blue); }
|
| 103 |
+
.text-red { color: var(--red); }
|
| 104 |
+
|
| 105 |
+
/* Business selector */
|
| 106 |
+
.biz-tabs { display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap; }
|
| 107 |
+
.biz-tab { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
| 108 |
+
background: var(--surface); cursor: pointer; font-size: 12px; color: var(--muted);
|
| 109 |
+
transition: all .15s; }
|
| 110 |
+
.biz-tab.active { background: #1e3a8a; color: #93c5fd; border-color: #3b82f6; }
|
| 111 |
+
|
| 112 |
+
.offline-banner { background: #78350f; border: 1px solid #f97316; border-radius: 8px;
|
| 113 |
+
padding: 10px 14px; margin-bottom: 14px; font-size: 12px; color: #fcd34d;
|
| 114 |
+
display: none; }
|
| 115 |
+
.offline-banner.show { display: block; }
|
| 116 |
+
|
| 117 |
+
footer { text-align: center; color: var(--muted); font-size: 11px; padding: 20px 0 10px; }
|
| 118 |
+
</style>
|
| 119 |
+
</head>
|
| 120 |
+
<body>
|
| 121 |
+
<div class="container">
|
| 122 |
+
|
| 123 |
+
<!-- Header -->
|
| 124 |
+
<div class="header">
|
| 125 |
+
<div>
|
| 126 |
+
<h1>β‘ Grid Outage Forecaster</h1>
|
| 127 |
+
<div style="color:var(--muted);font-size:12px;margin-top:3px;">T2.3 Β· AIMS KTT Hackathon 2026 Β· Kigali, Rwanda</div>
|
| 128 |
+
</div>
|
| 129 |
+
<div class="header-meta">
|
| 130 |
+
<div class="metric">
|
| 131 |
+
<div class="metric-val">0.176</div>
|
| 132 |
+
<div class="metric-lbl">Brier Score</div>
|
| 133 |
+
</div>
|
| 134 |
+
<div class="metric">
|
| 135 |
+
<div class="metric-val">61.2</div>
|
| 136 |
+
<div class="metric-lbl">MAE (min)</div>
|
| 137 |
+
</div>
|
| 138 |
+
<div class="metric">
|
| 139 |
+
<div class="metric-val">2.79h</div>
|
| 140 |
+
<div class="metric-lbl">Avg Lead Time</div>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<!-- Business selector -->
|
| 146 |
+
<div class="biz-tabs">
|
| 147 |
+
<div style="color:var(--muted);font-size:12px;align-self:center;margin-right:4px;">Business:</div>
|
| 148 |
+
<div class="biz-tab active" onclick="switchBiz('salon',this)">π Beauty Salon</div>
|
| 149 |
+
<div class="biz-tab" onclick="switchBiz('cold_room',this)">π§ Cold Room</div>
|
| 150 |
+
<div class="biz-tab" onclick="switchBiz('tailor',this)">π§΅ Tailor Shop</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<!-- Offline banner -->
|
| 154 |
+
<div class="offline-banner" id="offlineBanner">
|
| 155 |
+
β οΈ <strong>OFFLINE MODE</strong> β Forecast last updated <span id="staleTime"></span>.
|
| 156 |
+
Plan valid for 6 hours from generation. After 13:00 without refresh, treat HIGH-risk hours as confirmed.
|
| 157 |
+
Call 0788-GRID for live status.
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
<!-- Tabs -->
|
| 161 |
+
<div class="tabs">
|
| 162 |
+
<div class="tab active" onclick="showTab('forecast',this)">π Forecast</div>
|
| 163 |
+
<div class="tab" onclick="showTab('plan',this)">π Appliance Plan</div>
|
| 164 |
+
<div class="tab" onclick="showTab('sms',this)">π± SMS Digest</div>
|
| 165 |
+
<div class="tab" onclick="showTab('about',this)">βΉοΈ About</div>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<!-- FORECAST TAB -->
|
| 169 |
+
<div class="panel active" id="tab-forecast">
|
| 170 |
+
<div class="chart-wrap">
|
| 171 |
+
<div class="chart-legend">
|
| 172 |
+
<div class="legend-item"><div class="legend-dot" style="background:#6366f1"></div>P(outage)</div>
|
| 173 |
+
<div class="legend-item"><div class="legend-dot" style="background:rgba(99,102,241,.25)"></div>Uncertainty band</div>
|
| 174 |
+
<div class="legend-item"><div class="legend-dot" style="background:#22c55e"></div>LOW risk <12%</div>
|
| 175 |
+
<div class="legend-item"><div class="legend-dot" style="background:#f97316"></div>MEDIUM 12β25%</div>
|
| 176 |
+
<div class="legend-item"><div class="legend-dot" style="background:#ef4444"></div>HIGH >25%</div>
|
| 177 |
+
</div>
|
| 178 |
+
<canvas id="forecastChart" height="220"></canvas>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
<h2 style="margin-bottom:8px">Hourly Risk β click a cell to drill into plan</h2>
|
| 182 |
+
<div class="hour-grid" id="hourGrid"></div>
|
| 183 |
+
|
| 184 |
+
<div class="summary-bar" id="summaryBar"></div>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<!-- PLAN TAB -->
|
| 188 |
+
<div class="panel" id="tab-plan">
|
| 189 |
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
|
| 190 |
+
<h2 id="planHourLabel">Hour 0 Β· 00:00</h2>
|
| 191 |
+
<div style="display:flex;gap:8px;align-items:center;">
|
| 192 |
+
<button onclick="changeHour(-1)" style="background:var(--surface2);border:1px solid var(--border);
|
| 193 |
+
color:var(--text);padding:4px 10px;border-radius:5px;cursor:pointer;">β</button>
|
| 194 |
+
<span id="planHourNum" style="font-size:13px;color:var(--muted)">Hour 0</span>
|
| 195 |
+
<button onclick="changeHour(1)" style="background:var(--surface2);border:1px solid var(--border);
|
| 196 |
+
color:var(--text);padding:4px 10px;border-radius:5px;cursor:pointer;">βΆ</button>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
<div class="ap-grid" id="applianceGrid"></div>
|
| 200 |
+
<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;
|
| 201 |
+
padding:12px;font-size:12px;color:var(--muted);margin-top:4px;">
|
| 202 |
+
<strong style="color:var(--text)">Shedding Logic:</strong>
|
| 203 |
+
Luxury β Comfort β Critical (never shed during peak unless P > 0.50).
|
| 204 |
+
Within category: lowest revenue shed first. Critical always ON during business peak hours.
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
<!-- SMS TAB -->
|
| 209 |
+
<div class="panel" id="tab-sms">
|
| 210 |
+
<h2>π± Morning Digest β Feature Phone SMS</h2>
|
| 211 |
+
<p style="color:var(--muted);font-size:12px;margin-bottom:14px;">
|
| 212 |
+
Sent at 06:30 CAT. Max 3 messages Γ 160 chars. Works on any GSM phone. No internet required.
|
| 213 |
+
Language: Kinyarwanda/English mix for maximum reach.
|
| 214 |
+
</p>
|
| 215 |
+
<div id="smsBox"></div>
|
| 216 |
+
<div class="sms-box" style="border-color:#6366f1;margin-top:16px;">
|
| 217 |
+
<div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:8px;">
|
| 218 |
+
π Offline Fallback Protocol
|
| 219 |
+
</div>
|
| 220 |
+
<div style="font-size:12px;color:var(--muted);line-height:1.7;">
|
| 221 |
+
<strong style="color:var(--text)">If no internet refresh by 13:00:</strong> Device shows last cached plan with
|
| 222 |
+
a red β οΈ staleness banner. Risk budget: plan valid for <strong style="color:var(--orange)">6 hours</strong>
|
| 223 |
+
from generation time. After 6h, all HIGH-risk flags remain but MEDIUM degrades to LOW (overly cautious).
|
| 224 |
+
Maximum acceptable staleness before stopping to trust the plan: <strong style="color:var(--red)">8 hours</strong>.
|
| 225 |
+
Owner sees: "PLAN STALE β use generator, call 0788-GRID."
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
<div class="sms-box" style="border-color:#22c55e;margin-top:10px;">
|
| 229 |
+
<div style="font-size:12px;font-weight:700;color:var(--green);margin-bottom:8px;">
|
| 230 |
+
π Illiteracy Adaptation β Voice + LED Relay
|
| 231 |
+
</div>
|
| 232 |
+
<div style="font-size:12px;color:var(--muted);line-height:1.7;">
|
| 233 |
+
<strong style="color:var(--text)">Design choice: Colored LED relay board</strong> (3 LEDs per appliance slot).
|
| 234 |
+
<br>π’ GREEN = ON safe Β· π‘ YELLOW = shed if load high Β· π΄ RED = OFF now.
|
| 235 |
+
<br>Board connects via GPIO to a βUSD 8 ESP32 running cached plan. No reading required.
|
| 236 |
+
Physical override switch lets owner override any LED. Justification: LEDs are universal,
|
| 237 |
+
no language barrier, no smartphone needed, $8 hardware cost, zero ongoing data cost.
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
<!-- ABOUT TAB -->
|
| 243 |
+
<div class="panel" id="tab-about">
|
| 244 |
+
<h2>Technical Notes</h2>
|
| 245 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
| 246 |
+
<div class="sms-box">
|
| 247 |
+
<div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:6px;">Model</div>
|
| 248 |
+
<div style="font-size:12px;color:var(--muted);line-height:1.7;">
|
| 249 |
+
<strong style="color:var(--text)">LightGBM</strong> classifier for P(outage) + regressor for E[duration | outage].
|
| 250 |
+
Features: lagged load (1h, 2h, 24h, 48h), rolling stats, weather (temp, humidity, rain, wind),
|
| 251 |
+
temporal (hour, DOW, month, peak flags, rainy season). Training: 150-day window.
|
| 252 |
+
Evaluation: rolling 30-day held-out.
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
<div class="sms-box">
|
| 256 |
+
<div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:6px;">Performance</div>
|
| 257 |
+
<div style="font-size:12px;color:var(--muted);line-height:1.7;">
|
| 258 |
+
Brier score: <strong style="color:var(--green)">0.1756</strong> (naΓ―ve base rate = ~0.212)<br>
|
| 259 |
+
Duration MAE: <strong style="color:var(--green)">61.2 min</strong><br>
|
| 260 |
+
Avg lead time on true outages: <strong style="color:var(--green)">2.79h</strong><br>
|
| 261 |
+
Inference latency: <strong style="color:var(--green)"><300ms CPU</strong><br>
|
| 262 |
+
Retraining time: <strong style="color:var(--green)"><10 min</strong>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
<div class="sms-box">
|
| 266 |
+
<div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:6px;">Constraints Met</div>
|
| 267 |
+
<div style="font-size:12px;color:var(--muted);line-height:1.7;">
|
| 268 |
+
β
CPU-only Β· β
<10 min retrain Β· β
<300ms serve<br>
|
| 269 |
+
β
50KB static UI Β· β
Feature phone SMS digest<br>
|
| 270 |
+
β
Offline fallback protocol Β· β
Illiteracy adaptation<br>
|
| 271 |
+
β
3 business archetypes Β· β
Critical-before-luxury rule
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
<div class="sms-box">
|
| 275 |
+
<div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:6px;">Hardest Trade-off</div>
|
| 276 |
+
<div style="font-size:12px;color:var(--muted);line-height:1.7;">
|
| 277 |
+
Chose LightGBM over Prophet: faster retrain, handles irregular time steps,
|
| 278 |
+
natively supports tabular weather features. Trade-off: less interpretable
|
| 279 |
+
seasonality decomposition. Compensated with explicit hour/DOW/month features
|
| 280 |
+
and SHAP values available in eval notebook.
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
<footer>T2.3 Β· Grid Outage Forecaster + Appliance Prioritizer Β· AIMS KTT Hackathon 2026 Β· CPU-only Β· <50KB</footer>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
<script>
|
| 290 |
+
// ββ Embedded Data βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 291 |
+
const FORECAST = [{"hour_offset":0,"timestamp":"2024-06-29 00:00","hour":0,"p_outage":0.2708,"p_outage_low":0.1908,"p_outage_high":0.3508,"expected_duration_min":89.8,"risk_level":"HIGH"},{"hour_offset":1,"timestamp":"2024-06-29 01:00","hour":1,"p_outage":0.2554,"p_outage_low":0.1754,"p_outage_high":0.3354,"expected_duration_min":83.2,"risk_level":"HIGH"},{"hour_offset":2,"timestamp":"2024-06-29 02:00","hour":2,"p_outage":0.2169,"p_outage_low":0.1369,"p_outage_high":0.2969,"expected_duration_min":85.0,"risk_level":"MEDIUM"},{"hour_offset":3,"timestamp":"2024-06-29 03:00","hour":3,"p_outage":0.2554,"p_outage_low":0.1754,"p_outage_high":0.3354,"expected_duration_min":85.0,"risk_level":"HIGH"},{"hour_offset":4,"timestamp":"2024-06-29 04:00","hour":4,"p_outage":0.2602,"p_outage_low":0.1802,"p_outage_high":0.3402,"expected_duration_min":78.8,"risk_level":"HIGH"},{"hour_offset":5,"timestamp":"2024-06-29 05:00","hour":5,"p_outage":0.2503,"p_outage_low":0.1703,"p_outage_high":0.3303,"expected_duration_min":85.0,"risk_level":"HIGH"},{"hour_offset":6,"timestamp":"2024-06-29 06:00","hour":6,"p_outage":0.24,"p_outage_low":0.16,"p_outage_high":0.32,"expected_duration_min":83.2,"risk_level":"MEDIUM"},{"hour_offset":7,"timestamp":"2024-06-29 07:00","hour":7,"p_outage":0.2208,"p_outage_low":0.1408,"p_outage_high":0.3008,"expected_duration_min":78.5,"risk_level":"MEDIUM"},{"hour_offset":8,"timestamp":"2024-06-29 08:00","hour":8,"p_outage":0.2208,"p_outage_low":0.1408,"p_outage_high":0.3008,"expected_duration_min":78.5,"risk_level":"MEDIUM"},{"hour_offset":9,"timestamp":"2024-06-29 09:00","hour":9,"p_outage":0.198,"p_outage_low":0.118,"p_outage_high":0.278,"expected_duration_min":86.0,"risk_level":"MEDIUM"},{"hour_offset":10,"timestamp":"2024-06-29 10:00","hour":10,"p_outage":0.24,"p_outage_low":0.16,"p_outage_high":0.32,"expected_duration_min":71.3,"risk_level":"MEDIUM"},{"hour_offset":11,"timestamp":"2024-06-29 11:00","hour":11,"p_outage":0.2531,"p_outage_low":0.1731,"p_outage_high":0.3331,"expected_duration_min":73.1,"risk_level":"HIGH"},{"hour_offset":12,"timestamp":"2024-06-29 12:00","hour":12,"p_outage":0.2457,"p_outage_low":0.1657,"p_outage_high":0.3257,"expected_duration_min":76.9,"risk_level":"MEDIUM"},{"hour_offset":13,"timestamp":"2024-06-29 13:00","hour":13,"p_outage":0.263,"p_outage_low":0.183,"p_outage_high":0.343,"expected_duration_min":68.8,"risk_level":"HIGH"},{"hour_offset":14,"timestamp":"2024-06-29 14:00","hour":14,"p_outage":0.2582,"p_outage_low":0.1782,"p_outage_high":0.3382,"expected_duration_min":72.5,"risk_level":"HIGH"},{"hour_offset":15,"timestamp":"2024-06-29 15:00","hour":15,"p_outage":0.2194,"p_outage_low":0.1394,"p_outage_high":0.2994,"expected_duration_min":76.9,"risk_level":"MEDIUM"},{"hour_offset":16,"timestamp":"2024-06-29 16:00","hour":16,"p_outage":0.2688,"p_outage_low":0.1888,"p_outage_high":0.3488,"expected_duration_min":83.4,"risk_level":"HIGH"},{"hour_offset":17,"timestamp":"2024-06-29 17:00","hour":17,"p_outage":0.309,"p_outage_low":0.229,"p_outage_high":0.389,"expected_duration_min":84.6,"risk_level":"HIGH"},{"hour_offset":18,"timestamp":"2024-06-29 18:00","hour":18,"p_outage":0.3353,"p_outage_low":0.2553,"p_outage_high":0.4153,"expected_duration_min":84.6,"risk_level":"HIGH"},{"hour_offset":19,"timestamp":"2024-06-29 19:00","hour":19,"p_outage":0.3408,"p_outage_low":0.2608,"p_outage_high":0.4208,"expected_duration_min":76.1,"risk_level":"HIGH"},{"hour_offset":20,"timestamp":"2024-06-29 20:00","hour":20,"p_outage":0.3353,"p_outage_low":0.2553,"p_outage_high":0.4153,"expected_duration_min":99.4,"risk_level":"HIGH"},{"hour_offset":21,"timestamp":"2024-06-29 21:00","hour":21,"p_outage":0.3466,"p_outage_low":0.2666,"p_outage_high":0.4266,"expected_duration_min":100.6,"risk_level":"HIGH"},{"hour_offset":22,"timestamp":"2024-06-29 22:00","hour":22,"p_outage":0.2834,"p_outage_low":0.2034,"p_outage_high":0.3634,"expected_duration_min":102.5,"risk_level":"HIGH"},{"hour_offset":23,"timestamp":"2024-06-29 23:00","hour":23,"p_outage":0.2596,"p_outage_low":0.1796,"p_outage_high":0.3396,"expected_duration_min":106.9,"risk_level":"HIGH"}];
|
| 292 |
+
|
| 293 |
+
const PLANS = {"salon":{"business":"Beauty Salon (Kigali)","summary":{"total_revenue_plan_rwf":93850,"total_revenue_naive_rwf":101790,"revenue_saved_rwf":-7940,"disruption_penalty_avoided_rwf":20358,"net_benefit_rwf":12418,"hours_with_shed":24},"plan":[{"hour":0,"timestamp":"2024-06-29 00:00","risk_level":"HIGH","p_outage":0.2708,"expected_duration_min":89.8,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"OFF","watts":2400,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Electric Clippers (3Γ)","category":"critical","state":"OFF","watts":120,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":1,"timestamp":"2024-06-29 01:00","risk_level":"HIGH","p_outage":0.2554,"expected_duration_min":83.2,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"OFF","watts":2400,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Electric Clippers (3Γ)","category":"critical","state":"OFF","watts":120,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":2,"timestamp":"2024-06-29 02:00","risk_level":"MEDIUM","p_outage":0.2169,"expected_duration_min":85,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"OFF","watts":2400,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Electric Clippers (3Γ)","category":"critical","state":"OFF","watts":120,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":3,"timestamp":"2024-06-29 03:00","risk_level":"HIGH","p_outage":0.2554,"expected_duration_min":85,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"OFF","watts":2400,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Electric Clippers (3Γ)","category":"critical","state":"OFF","watts":120,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":4,"timestamp":"2024-06-29 04:00","risk_level":"HIGH","p_outage":0.2602,"expected_duration_min":78.8,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"OFF","watts":2400,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Electric Clippers (3Γ)","category":"critical","state":"OFF","watts":120,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":5,"timestamp":"2024-06-29 05:00","risk_level":"HIGH","p_outage":0.2503,"expected_duration_min":85,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"OFF","watts":2400,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Electric Clippers (3Γ)","category":"critical","state":"OFF","watts":120,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":6,"timestamp":"2024-06-29 06:00","risk_level":"MEDIUM","p_outage":0.24,"expected_duration_min":83.2,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"OFF","watts":2400,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Electric Clippers (3Γ)","category":"critical","state":"OFF","watts":120,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":7,"timestamp":"2024-06-29 07:00","risk_level":"MEDIUM","p_outage":0.2208,"expected_duration_min":78.5,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":1600},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1067},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":533},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":214},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":107},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":8,"timestamp":"2024-06-29 08:00","risk_level":"MEDIUM","p_outage":0.2208,"expected_duration_min":78.5,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":1600},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1067},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":533},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":214},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":107},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":9,"timestamp":"2024-06-29 09:00","risk_level":"MEDIUM","p_outage":0.198,"expected_duration_min":86,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":2133},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1422},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":711},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":285},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":142},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":10,"timestamp":"2024-06-29 10:00","risk_level":"MEDIUM","p_outage":0.24,"expected_duration_min":71.3,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":2133},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1422},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":711},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":285},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":142},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":11,"timestamp":"2024-06-29 11:00","risk_level":"HIGH","p_outage":0.2531,"expected_duration_min":73.1,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":2133},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1422},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":711},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":12,"timestamp":"2024-06-29 12:00","risk_level":"MEDIUM","p_outage":0.2457,"expected_duration_min":76.9,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":2133},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1422},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":711},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":285},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":142},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":13,"timestamp":"2024-06-29 13:00","risk_level":"HIGH","p_outage":0.263,"expected_duration_min":68.8,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":2133},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1422},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":711},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":14,"timestamp":"2024-06-29 14:00","risk_level":"HIGH","p_outage":0.2582,"expected_duration_min":72.5,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":2133},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1422},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":711},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":15,"timestamp":"2024-06-29 15:00","risk_level":"MEDIUM","p_outage":0.2194,"expected_duration_min":76.9,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":2133},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1422},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":711},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":285},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":142},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":16,"timestamp":"2024-06-29 16:00","risk_level":"HIGH","p_outage":0.2688,"expected_duration_min":83.4,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":2133},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1422},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":711},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":17,"timestamp":"2024-06-29 17:00","risk_level":"HIGH","p_outage":0.309,"expected_duration_min":84.6,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":2133},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1422},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":711},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":18,"timestamp":"2024-06-29 18:00","risk_level":"HIGH","p_outage":0.3353,"expected_duration_min":84.6,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":1600},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1067},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":533},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":19,"timestamp":"2024-06-29 19:00","risk_level":"HIGH","p_outage":0.3408,"expected_duration_min":76.1,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":1600},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1067},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":533},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":20,"timestamp":"2024-06-29 20:00","risk_level":"HIGH","p_outage":0.3353,"expected_duration_min":99.4,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":1600},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1067},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":533},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":21,"timestamp":"2024-06-29 21:00","risk_level":"HIGH","p_outage":0.3466,"expected_duration_min":100.6,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"OFF","watts":2400,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Electric Clippers (3Γ)","category":"critical","state":"OFF","watts":120,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":22,"timestamp":"2024-06-29 22:00","risk_level":"HIGH","p_outage":0.2834,"expected_duration_min":102.5,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"OFF","watts":2400,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Electric Clippers (3Γ)","category":"critical","state":"OFF","watts":120,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":23,"timestamp":"2024-06-29 23:00","risk_level":"HIGH","p_outage":0.2596,"expected_duration_min":106.9,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"OFF","watts":2400,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Electric Clippers (3Γ)","category":"critical","state":"OFF","watts":120,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]}]},"cold_room":{"business":"Cold Room / Butchery","summary":{"total_revenue_plan_rwf":118000,"total_revenue_naive_rwf":125000,"revenue_saved_rwf":-7000,"disruption_penalty_avoided_rwf":25000,"net_benefit_rwf":18000,"hours_with_shed":16},"plan":[{"hour":0,"timestamp":"2024-06-29 00:00","risk_level":"HIGH","p_outage":0.2708,"expected_duration_min":89.8,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":200,"shed_reason":"After-hours β standby mode"},{"name":"Water Pump","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β pump off"},{"name":"LED Lights","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β lights off"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":1,"timestamp":"2024-06-29 01:00","risk_level":"HIGH","p_outage":0.2554,"expected_duration_min":83.2,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":200,"shed_reason":"After-hours β standby mode"},{"name":"Water Pump","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β pump off"},{"name":"LED Lights","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β lights off"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":2,"timestamp":"2024-06-29 02:00","risk_level":"MEDIUM","p_outage":0.2169,"expected_duration_min":85,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":200,"shed_reason":"After-hours β standby mode"},{"name":"Water Pump","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β pump off"},{"name":"LED Lights","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β lights off"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":3,"timestamp":"2024-06-29 03:00","risk_level":"HIGH","p_outage":0.2554,"expected_duration_min":85,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":200,"shed_reason":"After-hours β standby mode"},{"name":"Water Pump","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β pump off"},{"name":"LED Lights","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β lights off"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":4,"timestamp":"2024-06-29 04:00","risk_level":"HIGH","p_outage":0.2602,"expected_duration_min":78.8,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":200,"shed_reason":"After-hours β standby mode"},{"name":"Water Pump","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β pump off"},{"name":"LED Lights","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β lights off"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":5,"timestamp":"2024-06-29 05:00","risk_level":"HIGH","p_outage":0.2503,"expected_duration_min":85,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":200,"shed_reason":"After-hours β standby mode"},{"name":"Water Pump","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β pump off"},{"name":"LED Lights","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β lights off"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":6,"timestamp":"2024-06-29 06:00","risk_level":"MEDIUM","p_outage":0.24,"expected_duration_min":83.2,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1110},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":660},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":444},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":178},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":89},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":7,"timestamp":"2024-06-29 07:00","risk_level":"MEDIUM","p_outage":0.2208,"expected_duration_min":78.5,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1110},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":660},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":444},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":178},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":89},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":8,"timestamp":"2024-06-29 08:00","risk_level":"MEDIUM","p_outage":0.2208,"expected_duration_min":78.5,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1850},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":1100},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":740},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":296},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":148},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":9,"timestamp":"2024-06-29 09:00","risk_level":"MEDIUM","p_outage":0.198,"expected_duration_min":86,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1850},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":1100},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":740},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":296},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":148},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":10,"timestamp":"2024-06-29 10:00","risk_level":"MEDIUM","p_outage":0.24,"expected_duration_min":71.3,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1850},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":1100},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":740},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":296},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":148},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":11,"timestamp":"2024-06-29 11:00","risk_level":"HIGH","p_outage":0.2531,"expected_duration_min":73.1,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1850},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":1100},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":740},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":12,"timestamp":"2024-06-29 12:00","risk_level":"MEDIUM","p_outage":0.2457,"expected_duration_min":76.9,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1850},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":1100},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":740},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":296},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":148},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":13,"timestamp":"2024-06-29 13:00","risk_level":"HIGH","p_outage":0.263,"expected_duration_min":68.8,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1850},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":1100},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":740},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":14,"timestamp":"2024-06-29 14:00","risk_level":"HIGH","p_outage":0.2582,"expected_duration_min":72.5,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1850},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":1100},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":740},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":15,"timestamp":"2024-06-29 15:00","risk_level":"MEDIUM","p_outage":0.2194,"expected_duration_min":76.9,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1850},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":1100},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":740},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":296},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":148},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":16,"timestamp":"2024-06-29 16:00","risk_level":"HIGH","p_outage":0.2688,"expected_duration_min":83.4,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1850},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":1100},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":740},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":17,"timestamp":"2024-06-29 17:00","risk_level":"HIGH","p_outage":0.309,"expected_duration_min":84.6,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1850},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":1100},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":740},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":18,"timestamp":"2024-06-29 18:00","risk_level":"HIGH","p_outage":0.3353,"expected_duration_min":84.6,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1850},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":1100},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":740},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":19,"timestamp":"2024-06-29 19:00","risk_level":"HIGH","p_outage":0.3408,"expected_duration_min":76.1,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1110},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":660},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":444},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":20,"timestamp":"2024-06-29 20:00","risk_level":"HIGH","p_outage":0.3353,"expected_duration_min":99.4,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1110},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":660},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":444},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":21,"timestamp":"2024-06-29 21:00","risk_level":"HIGH","p_outage":0.3466,"expected_duration_min":100.6,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":200,"shed_reason":"After-hours β standby mode"},{"name":"Water Pump","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β pump off"},{"name":"LED Lights","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β lights off"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":22,"timestamp":"2024-06-29 22:00","risk_level":"HIGH","p_outage":0.2834,"expected_duration_min":102.5,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":200,"shed_reason":"After-hours β standby mode"},{"name":"Water Pump","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β pump off"},{"name":"LED Lights","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β lights off"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":23,"timestamp":"2024-06-29 23:00","risk_level":"HIGH","p_outage":0.2596,"expected_duration_min":106.9,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":200,"shed_reason":"After-hours β standby mode"},{"name":"Water Pump","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β pump off"},{"name":"LED Lights","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"After-hours β lights off"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Backup Battery Charger","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]}]},"tailor":{"business":"Tailor Shop","summary":{"total_revenue_plan_rwf":42000,"total_revenue_naive_rwf":48000,"revenue_saved_rwf":-6000,"disruption_penalty_avoided_rwf":9600,"net_benefit_rwf":3600,"hours_with_shed":14},"plan":[{"hour":0,"timestamp":"2024-06-29 00:00","risk_level":"HIGH","p_outage":0.2708,"expected_duration_min":89.8,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":1,"timestamp":"2024-06-29 01:00","risk_level":"HIGH","p_outage":0.2554,"expected_duration_min":83.2,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":2,"timestamp":"2024-06-29 02:00","risk_level":"MEDIUM","p_outage":0.2169,"expected_duration_min":85,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":3,"timestamp":"2024-06-29 03:00","risk_level":"HIGH","p_outage":0.2554,"expected_duration_min":85,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":4,"timestamp":"2024-06-29 04:00","risk_level":"HIGH","p_outage":0.2602,"expected_duration_min":78.8,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":5,"timestamp":"2024-06-29 05:00","risk_level":"HIGH","p_outage":0.2503,"expected_duration_min":85,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":6,"timestamp":"2024-06-29 06:00","risk_level":"MEDIUM","p_outage":0.24,"expected_duration_min":83.2,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":7,"timestamp":"2024-06-29 07:00","risk_level":"MEDIUM","p_outage":0.2208,"expected_duration_min":78.5,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":8,"timestamp":"2024-06-29 08:00","risk_level":"MEDIUM","p_outage":0.2208,"expected_duration_min":78.5,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"ON","watts":180,"revenue_rwf":354},{"name":"Overlocker","category":"critical","state":"ON","watts":100,"revenue_rwf":186},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":108},{"name":"Iron Press","category":"comfort","state":"ON","watts":1000,"revenue_rwf":156},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":72},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":9,"timestamp":"2024-06-29 09:00","risk_level":"MEDIUM","p_outage":0.198,"expected_duration_min":86,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"ON","watts":180,"revenue_rwf":590},{"name":"Overlocker","category":"critical","state":"ON","watts":100,"revenue_rwf":310},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":180},{"name":"Iron Press","category":"comfort","state":"ON","watts":1000,"revenue_rwf":260},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":120},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":10,"timestamp":"2024-06-29 10:00","risk_level":"MEDIUM","p_outage":0.24,"expected_duration_min":71.3,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"ON","watts":180,"revenue_rwf":590},{"name":"Overlocker","category":"critical","state":"ON","watts":100,"revenue_rwf":310},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":180},{"name":"Iron Press","category":"comfort","state":"ON","watts":1000,"revenue_rwf":260},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":120},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":11,"timestamp":"2024-06-29 11:00","risk_level":"HIGH","p_outage":0.2531,"expected_duration_min":73.1,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"ON","watts":180,"revenue_rwf":590},{"name":"Overlocker","category":"critical","state":"ON","watts":100,"revenue_rwf":310},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":180},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β heavy load shed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":12,"timestamp":"2024-06-29 12:00","risk_level":"MEDIUM","p_outage":0.2457,"expected_duration_min":76.9,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"ON","watts":180,"revenue_rwf":590},{"name":"Overlocker","category":"critical","state":"ON","watts":100,"revenue_rwf":310},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":180},{"name":"Iron Press","category":"comfort","state":"ON","watts":1000,"revenue_rwf":260},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":120},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":13,"timestamp":"2024-06-29 13:00","risk_level":"HIGH","p_outage":0.263,"expected_duration_min":68.8,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"ON","watts":180,"revenue_rwf":590},{"name":"Overlocker","category":"critical","state":"ON","watts":100,"revenue_rwf":310},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":180},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β heavy load shed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":14,"timestamp":"2024-06-29 14:00","risk_level":"HIGH","p_outage":0.2582,"expected_duration_min":72.5,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"ON","watts":180,"revenue_rwf":590},{"name":"Overlocker","category":"critical","state":"ON","watts":100,"revenue_rwf":310},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":180},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β heavy load shed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":15,"timestamp":"2024-06-29 15:00","risk_level":"MEDIUM","p_outage":0.2194,"expected_duration_min":76.9,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"ON","watts":180,"revenue_rwf":590},{"name":"Overlocker","category":"critical","state":"ON","watts":100,"revenue_rwf":310},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":180},{"name":"Iron Press","category":"comfort","state":"ON","watts":1000,"revenue_rwf":260},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":120},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":16,"timestamp":"2024-06-29 16:00","risk_level":"HIGH","p_outage":0.2688,"expected_duration_min":83.4,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"ON","watts":180,"revenue_rwf":590},{"name":"Overlocker","category":"critical","state":"ON","watts":100,"revenue_rwf":310},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":180},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β heavy load shed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":17,"timestamp":"2024-06-29 17:00","risk_level":"HIGH","p_outage":0.309,"expected_duration_min":84.6,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"ON","watts":180,"revenue_rwf":354},{"name":"Overlocker","category":"critical","state":"ON","watts":100,"revenue_rwf":186},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":108},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β heavy load shed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":18,"timestamp":"2024-06-29 18:00","risk_level":"HIGH","p_outage":0.3353,"expected_duration_min":84.6,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"ON","watts":180,"revenue_rwf":354},{"name":"Overlocker","category":"critical","state":"ON","watts":100,"revenue_rwf":186},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":108},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β heavy load shed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"HIGH risk β comfort shed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Risk β₯ MEDIUM β luxury shed"}]},{"hour":19,"timestamp":"2024-06-29 19:00","risk_level":"HIGH","p_outage":0.3408,"expected_duration_min":76.1,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":20,"timestamp":"2024-06-29 20:00","risk_level":"HIGH","p_outage":0.3353,"expected_duration_min":99.4,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":21,"timestamp":"2024-06-29 21:00","risk_level":"HIGH","p_outage":0.3466,"expected_duration_min":100.6,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":22,"timestamp":"2024-06-29 22:00","risk_level":"HIGH","p_outage":0.2834,"expected_duration_min":102.5,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]},{"hour":23,"timestamp":"2024-06-29 23:00","risk_level":"HIGH","p_outage":0.2596,"expected_duration_min":106.9,"appliances":[{"name":"Sewing Machine (2Γ)","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Overlocker","category":"critical","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"LED Lights","category":"critical","state":"ON","watts":20,"revenue_rwf":0},{"name":"Iron Press","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0,"shed_reason":"Business closed"}]}]}};
|
| 294 |
+
|
| 295 |
+
const SMS = [
|
| 296 |
+
"UMURIRO FORECAST 24H: Risk=HIGH at 0h,1h,3h. Shed: Standing+TV. Est.save: 12,418RWF. Stay alert!",
|
| 297 |
+
"PLAN: Turn OFF Standing+TV during risk hrs (0h,1h,3h). Keep dryer+clippers+lights ON. Generator ready?",
|
| 298 |
+
"If no signal by 13h, use YESTERDAY plan. Risk valid 6h. Call 0788-GRID for live update. Good business!"
|
| 299 |
+
];
|
| 300 |
+
|
| 301 |
+
// ββ Pre-build hrs[0..23] for each business ββββββββββββββββββββββββββββββββββββ
|
| 302 |
+
// plan is now a full 24-entry array (one per hour), so hrs is a direct alias
|
| 303 |
+
(function buildHrs() {
|
| 304 |
+
Object.values(PLANS).forEach(p => { p.hrs = p.plan; });
|
| 305 |
+
})();
|
| 306 |
+
|
| 307 |
+
// ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 308 |
+
let currentBiz = 'salon';
|
| 309 |
+
let selectedHour = 0;
|
| 310 |
+
|
| 311 |
+
// ββ Tab switching βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 312 |
+
function showTab(id, el) {
|
| 313 |
+
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
| 314 |
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 315 |
+
document.getElementById('tab-' + id).classList.add('active');
|
| 316 |
+
el.classList.add('active');
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
function switchBiz(biz, el) {
|
| 320 |
+
currentBiz = biz;
|
| 321 |
+
document.querySelectorAll('.biz-tab').forEach(t => t.classList.remove('active'));
|
| 322 |
+
el.classList.add('active');
|
| 323 |
+
renderPlan(selectedHour);
|
| 324 |
+
renderSummary();
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
function changeHour(delta) {
|
| 328 |
+
selectedHour = Math.max(0, Math.min(23, selectedHour + delta));
|
| 329 |
+
renderPlan(selectedHour);
|
| 330 |
+
document.querySelectorAll('.hour-cell').forEach((c, i) => {
|
| 331 |
+
c.classList.toggle('selected', i === selectedHour);
|
| 332 |
+
});
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
// ββ Chart (pure canvas, no library) ββββββββββββββββββββββββββββββββββββββββββ
|
| 336 |
+
function drawChart() {
|
| 337 |
+
const canvas = document.getElementById('forecastChart');
|
| 338 |
+
const dpr = window.devicePixelRatio || 1;
|
| 339 |
+
const W = canvas.parentElement.clientWidth - 32;
|
| 340 |
+
const H = 200;
|
| 341 |
+
canvas.width = W * dpr;
|
| 342 |
+
canvas.height = H * dpr;
|
| 343 |
+
canvas.style.width = W + 'px';
|
| 344 |
+
canvas.style.height = H + 'px';
|
| 345 |
+
const ctx = canvas.getContext('2d');
|
| 346 |
+
ctx.scale(dpr, dpr);
|
| 347 |
+
|
| 348 |
+
const pad = {l: 40, r: 10, t: 10, b: 30};
|
| 349 |
+
const cw = W - pad.l - pad.r;
|
| 350 |
+
const ch = H - pad.t - pad.b;
|
| 351 |
+
const n = FORECAST.length;
|
| 352 |
+
|
| 353 |
+
ctx.clearRect(0, 0, W, H);
|
| 354 |
+
|
| 355 |
+
// Grid lines
|
| 356 |
+
ctx.strokeStyle = '#2e3350';
|
| 357 |
+
ctx.lineWidth = 1;
|
| 358 |
+
[0, 0.1, 0.2, 0.3, 0.4, 0.5].forEach(v => {
|
| 359 |
+
const y = pad.t + ch - v * ch / 0.5;
|
| 360 |
+
if (y < pad.t) return;
|
| 361 |
+
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + cw, y); ctx.stroke();
|
| 362 |
+
ctx.fillStyle = '#8892b0'; ctx.font = '10px sans-serif'; ctx.textAlign = 'right';
|
| 363 |
+
ctx.fillText((v * 100).toFixed(0) + '%', pad.l - 4, y + 4);
|
| 364 |
+
});
|
| 365 |
+
|
| 366 |
+
// Hour labels
|
| 367 |
+
FORECAST.forEach((d, i) => {
|
| 368 |
+
if (i % 4 !== 0) return;
|
| 369 |
+
const x = pad.l + (i / (n - 1)) * cw;
|
| 370 |
+
ctx.fillStyle = '#8892b0'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
|
| 371 |
+
ctx.fillText(d.hour + 'h', x, H - 6);
|
| 372 |
+
});
|
| 373 |
+
|
| 374 |
+
// Risk background zones
|
| 375 |
+
FORECAST.forEach((d, i) => {
|
| 376 |
+
const x = pad.l + (i / n) * cw;
|
| 377 |
+
const bw = cw / n;
|
| 378 |
+
let col = d.risk_level === 'HIGH' ? 'rgba(239,68,68,.07)' :
|
| 379 |
+
d.risk_level === 'MEDIUM' ? 'rgba(249,115,22,.05)' : 'transparent';
|
| 380 |
+
ctx.fillStyle = col;
|
| 381 |
+
ctx.fillRect(x, pad.t, bw, ch);
|
| 382 |
+
});
|
| 383 |
+
|
| 384 |
+
const xOf = i => pad.l + (i / (n - 1)) * cw;
|
| 385 |
+
const yOf = v => pad.t + ch - (v / 0.5) * ch;
|
| 386 |
+
|
| 387 |
+
// Uncertainty band
|
| 388 |
+
ctx.beginPath();
|
| 389 |
+
FORECAST.forEach((d, i) => { i === 0 ? ctx.moveTo(xOf(i), yOf(d.p_outage_high)) : ctx.lineTo(xOf(i), yOf(d.p_outage_high)); });
|
| 390 |
+
FORECAST.slice().reverse().forEach((d, i) => ctx.lineTo(xOf(n - 1 - i), yOf(d.p_outage_low)));
|
| 391 |
+
ctx.closePath();
|
| 392 |
+
ctx.fillStyle = 'rgba(99,102,241,.18)';
|
| 393 |
+
ctx.fill();
|
| 394 |
+
|
| 395 |
+
// Main line
|
| 396 |
+
ctx.beginPath();
|
| 397 |
+
ctx.strokeStyle = '#6366f1'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round';
|
| 398 |
+
FORECAST.forEach((d, i) => { i === 0 ? ctx.moveTo(xOf(i), yOf(d.p_outage)) : ctx.lineTo(xOf(i), yOf(d.p_outage)); });
|
| 399 |
+
ctx.stroke();
|
| 400 |
+
|
| 401 |
+
// Threshold line at 0.25
|
| 402 |
+
ctx.beginPath();
|
| 403 |
+
ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
|
| 404 |
+
ctx.moveTo(pad.l, yOf(0.25)); ctx.lineTo(pad.l + cw, yOf(0.25)); ctx.stroke();
|
| 405 |
+
ctx.setLineDash([]);
|
| 406 |
+
ctx.fillStyle = '#ef4444'; ctx.font = '9px sans-serif'; ctx.textAlign = 'left';
|
| 407 |
+
ctx.fillText('HIGH', pad.l + 2, yOf(0.25) - 3);
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
// ββ Hour Grid βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 411 |
+
function renderHourGrid() {
|
| 412 |
+
const grid = document.getElementById('hourGrid');
|
| 413 |
+
grid.innerHTML = '';
|
| 414 |
+
FORECAST.forEach((d, i) => {
|
| 415 |
+
const cls = d.risk_level === 'HIGH' ? 'hc-high' : d.risk_level === 'MEDIUM' ? 'hc-medium' : 'hc-low';
|
| 416 |
+
const cell = document.createElement('div');
|
| 417 |
+
cell.className = 'hour-cell' + (i === selectedHour ? ' selected' : '');
|
| 418 |
+
cell.innerHTML = `<div class="hc-hour">${d.hour}h</div>
|
| 419 |
+
<div class="hc-prob ${cls}">${(d.p_outage * 100).toFixed(0)}%</div>
|
| 420 |
+
<div style="font-size:9px;margin-top:2px"><span class="badge badge-${d.risk_level.toLowerCase()}">${d.risk_level}</span></div>`;
|
| 421 |
+
cell.onclick = () => {
|
| 422 |
+
selectedHour = i;
|
| 423 |
+
document.querySelectorAll('.hour-cell').forEach((c, j) => c.classList.toggle('selected', j === i));
|
| 424 |
+
renderPlan(i);
|
| 425 |
+
showTab('plan', document.querySelector('.tab:nth-child(2)'));
|
| 426 |
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 427 |
+
document.querySelectorAll('.tab')[1].classList.add('active');
|
| 428 |
+
};
|
| 429 |
+
grid.appendChild(cell);
|
| 430 |
+
});
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
// ββ Summary Bar βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 434 |
+
function renderSummary() {
|
| 435 |
+
const p = PLANS[currentBiz] || PLANS.salon;
|
| 436 |
+
const s = p.summary;
|
| 437 |
+
const highH = FORECAST.filter(f => f.risk_level === 'HIGH').length;
|
| 438 |
+
document.getElementById('summaryBar').innerHTML = `
|
| 439 |
+
<div class="sum-card"><div class="sum-val text-green">${(s.net_benefit_rwf/1000).toFixed(1)}K</div><div class="sum-lbl">Net Benefit (RWF)</div></div>
|
| 440 |
+
<div class="sum-card"><div class="sum-val text-red">${highH}</div><div class="sum-lbl">HIGH Risk Hours</div></div>
|
| 441 |
+
<div class="sum-card"><div class="sum-val text-orange">${s.hours_with_shed}</div><div class="sum-lbl">Hours with Shed</div></div>
|
| 442 |
+
<div class="sum-card"><div class="sum-val text-blue">${(s.total_revenue_plan_rwf/1000).toFixed(0)}K</div><div class="sum-lbl">Expected Rev (RWF)</div></div>`;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
// ββ Appliance Plan ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 446 |
+
function renderPlan(hourIdx) {
|
| 447 |
+
const p = PLANS[currentBiz] || PLANS.salon;
|
| 448 |
+
const hData = p.hrs[hourIdx]; // direct read β no reduce needed
|
| 449 |
+
const fc = FORECAST[hourIdx];
|
| 450 |
+
|
| 451 |
+
document.getElementById('planHourLabel').innerHTML =
|
| 452 |
+
`Hour ${hourIdx} Β· ${fc.timestamp.split(' ')[1]} Β·
|
| 453 |
+
<span class="badge badge-${fc.risk_level.toLowerCase()}">${fc.risk_level}</span>
|
| 454 |
+
P(outage)=${(fc.p_outage*100).toFixed(1)}% Exp.dur=${fc.expected_duration_min.toFixed(0)}min`;
|
| 455 |
+
document.getElementById('planHourNum').textContent = 'Hour ' + hourIdx;
|
| 456 |
+
|
| 457 |
+
const appliances = hData.appliances || [];
|
| 458 |
+
document.getElementById('applianceGrid').innerHTML = appliances.map(ap => `
|
| 459 |
+
<div class="ap-card${ap.state === 'OFF' ? ' off' : ''}">
|
| 460 |
+
<div class="ap-left">
|
| 461 |
+
<div class="ap-name">${ap.name}</div>
|
| 462 |
+
<div class="ap-meta">
|
| 463 |
+
<span class="badge badge-${ap.category}">${ap.category}</span>
|
| 464 |
+
<span class="badge badge-${ap.state.toLowerCase()}">${ap.state}</span>
|
| 465 |
+
</div>
|
| 466 |
+
${ap.shed_reason ? `<div style="font-size:10px;color:#9ca3af;margin-top:2px">${ap.shed_reason}</div>` : ''}
|
| 467 |
+
</div>
|
| 468 |
+
<div class="ap-right">
|
| 469 |
+
<div class="ap-watts">${ap.watts}W</div>
|
| 470 |
+
<div class="ap-rev">${ap.state === 'ON' ? ap.revenue_rwf.toLocaleString() + ' RWF/h' : 'β'}</div>
|
| 471 |
+
</div>
|
| 472 |
+
</div>`).join('');
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
// ββ SMS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 476 |
+
function renderSMS() {
|
| 477 |
+
document.getElementById('smsBox').innerHTML = SMS.map((msg, i) => `
|
| 478 |
+
<div class="sms-box">
|
| 479 |
+
<div class="sms-header">
|
| 480 |
+
<span class="sms-num">SMS ${i+1}/3</span>
|
| 481 |
+
<span class="sms-chars">${msg.length}/160 chars</span>
|
| 482 |
+
</div>
|
| 483 |
+
<div class="sms-text">${msg}</div>
|
| 484 |
+
</div>`).join('');
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
// ββ Offline detection βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 488 |
+
function checkOffline() {
|
| 489 |
+
if (!navigator.onLine) {
|
| 490 |
+
document.getElementById('offlineBanner').classList.add('show');
|
| 491 |
+
document.getElementById('staleTime').textContent = new Date().toLocaleTimeString();
|
| 492 |
+
}
|
| 493 |
+
}
|
| 494 |
+
window.addEventListener('offline', checkOffline);
|
| 495 |
+
|
| 496 |
+
// ββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 497 |
+
window.addEventListener('load', () => {
|
| 498 |
+
drawChart();
|
| 499 |
+
renderHourGrid();
|
| 500 |
+
renderPlan(0);
|
| 501 |
+
renderSummary();
|
| 502 |
+
renderSMS();
|
| 503 |
+
checkOffline();
|
| 504 |
+
window.addEventListener('resize', drawChart);
|
| 505 |
+
});
|
| 506 |
+
</script>
|
| 507 |
+
</body>
|
| 508 |
+
</html>
|
src/lite_v2_ui.html
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>T2.3 Β· Grid Outage Forecaster + Appliance Prioritizer</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #0f1117; --surface: #1a1d27; --surface2: #22263a;
|
| 10 |
+
--border: #2e3350; --text: #e8eaf6; --muted: #8892b0;
|
| 11 |
+
--red: #ef4444; --orange: #f97316; --yellow: #eab308;
|
| 12 |
+
--green: #22c55e; --blue: #3b82f6; --purple: #a855f7;
|
| 13 |
+
--accent: #6366f1;
|
| 14 |
+
}
|
| 15 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 16 |
+
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif;
|
| 17 |
+
font-size: 14px; line-height: 1.5; }
|
| 18 |
+
.container { max-width: 1100px; margin: 0 auto; padding: 16px; }
|
| 19 |
+
h1 { font-size: 1.3rem; font-weight: 700; color: var(--accent); }
|
| 20 |
+
h2 { font-size: 1rem; font-weight: 600; margin-bottom: 10px; color: var(--text); }
|
| 21 |
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px;
|
| 22 |
+
font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
| 23 |
+
.badge-high { background: #7f1d1d; color: #fca5a5; }
|
| 24 |
+
.badge-medium { background: #78350f; color: #fcd34d; }
|
| 25 |
+
.badge-low { background: #14532d; color: #86efac; }
|
| 26 |
+
.badge-on { background: #14532d; color: #86efac; }
|
| 27 |
+
.badge-off { background: #3f3f46; color: #a1a1aa; }
|
| 28 |
+
.badge-critical { background: #1e3a8a; color: #93c5fd; }
|
| 29 |
+
.badge-comfort { background: #4a1d96; color: #c4b5fd; }
|
| 30 |
+
.badge-luxury { background: #374151; color: #9ca3af; }
|
| 31 |
+
|
| 32 |
+
.header { display: flex; align-items: center; justify-content: space-between;
|
| 33 |
+
padding: 12px 16px; background: var(--surface); border-radius: 10px;
|
| 34 |
+
border: 1px solid var(--border); margin-bottom: 14px; }
|
| 35 |
+
.header-meta { display: flex; gap: 20px; align-items: center; }
|
| 36 |
+
.metric { text-align: center; }
|
| 37 |
+
.metric-val { font-size: 1.4rem; font-weight: 800; color: var(--accent); }
|
| 38 |
+
.metric-lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; }
|
| 39 |
+
|
| 40 |
+
.tabs { display: flex; gap: 4px; margin-bottom: 14px; }
|
| 41 |
+
.tab { padding: 7px 16px; border-radius: 6px; border: 1px solid var(--border);
|
| 42 |
+
background: var(--surface); cursor: pointer; font-size: 13px; color: var(--muted);
|
| 43 |
+
transition: all .15s; }
|
| 44 |
+
.tab.active { background: var(--accent); color: white; border-color: var(--accent); }
|
| 45 |
+
|
| 46 |
+
.panel { display: none; }
|
| 47 |
+
.panel.active { display: block; }
|
| 48 |
+
|
| 49 |
+
/* Chart */
|
| 50 |
+
.chart-wrap { position: relative; background: var(--surface); border: 1px solid var(--border);
|
| 51 |
+
border-radius: 10px; padding: 16px; margin-bottom: 14px; }
|
| 52 |
+
canvas { width: 100% !important; }
|
| 53 |
+
.chart-legend { display: flex; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; }
|
| 54 |
+
.legend-item { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); }
|
| 55 |
+
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
| 56 |
+
|
| 57 |
+
/* Hour grid */
|
| 58 |
+
.hour-grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; margin-bottom: 14px; }
|
| 59 |
+
.hour-cell { background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
| 60 |
+
padding: 6px 4px; text-align: center; cursor: pointer; transition: all .15s; }
|
| 61 |
+
.hour-cell:hover { border-color: var(--accent); }
|
| 62 |
+
.hour-cell.selected { border-color: var(--accent); background: var(--surface2); }
|
| 63 |
+
.hour-cell .hc-hour { font-size: 11px; color: var(--muted); }
|
| 64 |
+
.hour-cell .hc-prob { font-size: 13px; font-weight: 700; }
|
| 65 |
+
.hc-high { color: var(--red); }
|
| 66 |
+
.hc-medium { color: var(--orange); }
|
| 67 |
+
.hc-low { color: var(--green); }
|
| 68 |
+
|
| 69 |
+
/* Appliance table */
|
| 70 |
+
.ap-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 14px; }
|
| 71 |
+
@media (max-width: 600px) { .ap-grid { grid-template-columns: 1fr; } }
|
| 72 |
+
.ap-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
| 73 |
+
padding: 10px 12px; display: flex; align-items: center; justify-content: space-between; }
|
| 74 |
+
.ap-card.off { opacity: .65; border-color: #3f3f46; }
|
| 75 |
+
.ap-left { display: flex; flex-direction: column; gap: 3px; }
|
| 76 |
+
.ap-name { font-weight: 600; font-size: 13px; }
|
| 77 |
+
.ap-meta { display: flex; gap: 6px; }
|
| 78 |
+
.ap-right { text-align: right; }
|
| 79 |
+
.ap-watts { font-size: 11px; color: var(--muted); }
|
| 80 |
+
.ap-rev { font-size: 12px; color: var(--green); font-weight: 600; }
|
| 81 |
+
|
| 82 |
+
/* SMS */
|
| 83 |
+
.sms-box { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
|
| 84 |
+
padding: 12px 14px; margin-bottom: 10px; }
|
| 85 |
+
.sms-header { display: flex; justify-content: space-between; align-items: center;
|
| 86 |
+
margin-bottom: 6px; }
|
| 87 |
+
.sms-num { font-size: 11px; font-weight: 700; color: var(--accent); }
|
| 88 |
+
.sms-chars { font-size: 10px; color: var(--muted); }
|
| 89 |
+
.sms-text { font-family: monospace; font-size: 13px; color: var(--text); line-height: 1.6;
|
| 90 |
+
word-break: break-word; }
|
| 91 |
+
|
| 92 |
+
/* Summary bar */
|
| 93 |
+
.summary-bar { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
|
| 94 |
+
margin-bottom: 14px; }
|
| 95 |
+
@media (max-width: 600px) { .summary-bar { grid-template-columns: repeat(2, 1fr); } }
|
| 96 |
+
.sum-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
| 97 |
+
padding: 10px 12px; text-align: center; }
|
| 98 |
+
.sum-val { font-size: 1.1rem; font-weight: 800; }
|
| 99 |
+
.sum-lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; margin-top: 2px; }
|
| 100 |
+
.text-green { color: var(--green); }
|
| 101 |
+
.text-orange { color: var(--orange); }
|
| 102 |
+
.text-blue { color: var(--blue); }
|
| 103 |
+
.text-red { color: var(--red); }
|
| 104 |
+
|
| 105 |
+
/* Business selector */
|
| 106 |
+
.biz-tabs { display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap; }
|
| 107 |
+
.biz-tab { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
| 108 |
+
background: var(--surface); cursor: pointer; font-size: 12px; color: var(--muted);
|
| 109 |
+
transition: all .15s; }
|
| 110 |
+
.biz-tab.active { background: #1e3a8a; color: #93c5fd; border-color: #3b82f6; }
|
| 111 |
+
|
| 112 |
+
.offline-banner { background: #78350f; border: 1px solid #f97316; border-radius: 8px;
|
| 113 |
+
padding: 10px 14px; margin-bottom: 14px; font-size: 12px; color: #fcd34d;
|
| 114 |
+
display: none; }
|
| 115 |
+
.offline-banner.show { display: block; }
|
| 116 |
+
|
| 117 |
+
footer { text-align: center; color: var(--muted); font-size: 11px; padding: 20px 0 10px; }
|
| 118 |
+
</style>
|
| 119 |
+
</head>
|
| 120 |
+
<body>
|
| 121 |
+
<div class="container">
|
| 122 |
+
|
| 123 |
+
<!-- Header -->
|
| 124 |
+
<div class="header">
|
| 125 |
+
<div>
|
| 126 |
+
<h1>β‘ Grid Outage Forecaster</h1>
|
| 127 |
+
<div style="color:var(--muted);font-size:12px;margin-top:3px;">T2.3 Β· AIMS KTT Hackathon 2026 Β· Kigali, Rwanda</div>
|
| 128 |
+
</div>
|
| 129 |
+
<div class="header-meta">
|
| 130 |
+
<div class="metric">
|
| 131 |
+
<div class="metric-val">0.176</div>
|
| 132 |
+
<div class="metric-lbl">Brier Score</div>
|
| 133 |
+
</div>
|
| 134 |
+
<div class="metric">
|
| 135 |
+
<div class="metric-val">61.2</div>
|
| 136 |
+
<div class="metric-lbl">MAE (min)</div>
|
| 137 |
+
</div>
|
| 138 |
+
<div class="metric">
|
| 139 |
+
<div class="metric-val">2.79h</div>
|
| 140 |
+
<div class="metric-lbl">Avg Lead Time</div>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<!-- Business selector -->
|
| 146 |
+
<div class="biz-tabs">
|
| 147 |
+
<div style="color:var(--muted);font-size:12px;align-self:center;margin-right:4px;">Business:</div>
|
| 148 |
+
<div class="biz-tab active" onclick="switchBiz('salon',this)">π Beauty Salon</div>
|
| 149 |
+
<div class="biz-tab" onclick="switchBiz('cold_room',this)">π§ Cold Room</div>
|
| 150 |
+
<div class="biz-tab" onclick="switchBiz('tailor',this)">π§΅ Tailor Shop</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<!-- Offline banner -->
|
| 154 |
+
<div class="offline-banner" id="offlineBanner">
|
| 155 |
+
β οΈ <strong>OFFLINE MODE</strong> β Forecast last updated <span id="staleTime"></span>.
|
| 156 |
+
Plan valid for 6 hours from generation. After 13:00 without refresh, treat HIGH-risk hours as confirmed.
|
| 157 |
+
Call 0788-GRID for live status.
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
<!-- Tabs -->
|
| 161 |
+
<div class="tabs">
|
| 162 |
+
<div class="tab active" onclick="showTab('forecast',this)">π Forecast</div>
|
| 163 |
+
<div class="tab" onclick="showTab('plan',this)">π Appliance Plan</div>
|
| 164 |
+
<div class="tab" onclick="showTab('sms',this)">π± SMS Digest</div>
|
| 165 |
+
<div class="tab" onclick="showTab('about',this)">βΉοΈ About</div>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<!-- FORECAST TAB -->
|
| 169 |
+
<div class="panel active" id="tab-forecast">
|
| 170 |
+
<div class="chart-wrap">
|
| 171 |
+
<div class="chart-legend">
|
| 172 |
+
<div class="legend-item"><div class="legend-dot" style="background:#6366f1"></div>P(outage)</div>
|
| 173 |
+
<div class="legend-item"><div class="legend-dot" style="background:rgba(99,102,241,.25)"></div>Uncertainty band</div>
|
| 174 |
+
<div class="legend-item"><div class="legend-dot" style="background:#22c55e"></div>LOW risk <12%</div>
|
| 175 |
+
<div class="legend-item"><div class="legend-dot" style="background:#f97316"></div>MEDIUM 12β25%</div>
|
| 176 |
+
<div class="legend-item"><div class="legend-dot" style="background:#ef4444"></div>HIGH >25%</div>
|
| 177 |
+
</div>
|
| 178 |
+
<canvas id="forecastChart" height="220"></canvas>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
<h2 style="margin-bottom:8px">Hourly Risk β click a cell to drill into plan</h2>
|
| 182 |
+
<div class="hour-grid" id="hourGrid"></div>
|
| 183 |
+
|
| 184 |
+
<div class="summary-bar" id="summaryBar"></div>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<!-- PLAN TAB -->
|
| 188 |
+
<div class="panel" id="tab-plan">
|
| 189 |
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
|
| 190 |
+
<h2 id="planHourLabel">Hour 0 Β· 00:00</h2>
|
| 191 |
+
<div style="display:flex;gap:8px;align-items:center;">
|
| 192 |
+
<button onclick="changeHour(-1)" style="background:var(--surface2);border:1px solid var(--border);
|
| 193 |
+
color:var(--text);padding:4px 10px;border-radius:5px;cursor:pointer;">β</button>
|
| 194 |
+
<span id="planHourNum" style="font-size:13px;color:var(--muted)">Hour 0</span>
|
| 195 |
+
<button onclick="changeHour(1)" style="background:var(--surface2);border:1px solid var(--border);
|
| 196 |
+
color:var(--text);padding:4px 10px;border-radius:5px;cursor:pointer;">βΆ</button>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
<div class="ap-grid" id="applianceGrid"></div>
|
| 200 |
+
<div style="background:var(--surface);border:1px solid var(--border);border-radius:8px;
|
| 201 |
+
padding:12px;font-size:12px;color:var(--muted);margin-top:4px;">
|
| 202 |
+
<strong style="color:var(--text)">Shedding Logic:</strong>
|
| 203 |
+
Luxury β Comfort β Critical (never shed during peak unless P > 0.50).
|
| 204 |
+
Within category: lowest revenue shed first. Critical always ON during business peak hours.
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
<!-- SMS TAB -->
|
| 209 |
+
<div class="panel" id="tab-sms">
|
| 210 |
+
<h2>π± Morning Digest β Feature Phone SMS</h2>
|
| 211 |
+
<p style="color:var(--muted);font-size:12px;margin-bottom:14px;">
|
| 212 |
+
Sent at 06:30 CAT. Max 3 messages Γ 160 chars. Works on any GSM phone. No internet required.
|
| 213 |
+
Language: Kinyarwanda/English mix for maximum reach.
|
| 214 |
+
</p>
|
| 215 |
+
<div id="smsBox"></div>
|
| 216 |
+
<div class="sms-box" style="border-color:#6366f1;margin-top:16px;">
|
| 217 |
+
<div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:8px;">
|
| 218 |
+
π Offline Fallback Protocol
|
| 219 |
+
</div>
|
| 220 |
+
<div style="font-size:12px;color:var(--muted);line-height:1.7;">
|
| 221 |
+
<strong style="color:var(--text)">If no internet refresh by 13:00:</strong> Device shows last cached plan with
|
| 222 |
+
a red β οΈ staleness banner. Risk budget: plan valid for <strong style="color:var(--orange)">6 hours</strong>
|
| 223 |
+
from generation time. After 6h, all HIGH-risk flags remain but MEDIUM degrades to LOW (overly cautious).
|
| 224 |
+
Maximum acceptable staleness before stopping to trust the plan: <strong style="color:var(--red)">8 hours</strong>.
|
| 225 |
+
Owner sees: "PLAN STALE β use generator, call 0788-GRID."
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
<div class="sms-box" style="border-color:#22c55e;margin-top:10px;">
|
| 229 |
+
<div style="font-size:12px;font-weight:700;color:var(--green);margin-bottom:8px;">
|
| 230 |
+
π Illiteracy Adaptation β Voice + LED Relay
|
| 231 |
+
</div>
|
| 232 |
+
<div style="font-size:12px;color:var(--muted);line-height:1.7;">
|
| 233 |
+
<strong style="color:var(--text)">Design choice: Colored LED relay board</strong> (3 LEDs per appliance slot).
|
| 234 |
+
<br>π’ GREEN = ON safe Β· π‘ YELLOW = shed if load high Β· π΄ RED = OFF now.
|
| 235 |
+
<br>Board connects via GPIO to a βUSD 8 ESP32 running cached plan. No reading required.
|
| 236 |
+
Physical override switch lets owner override any LED. Justification: LEDs are universal,
|
| 237 |
+
no language barrier, no smartphone needed, $8 hardware cost, zero ongoing data cost.
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
<!-- ABOUT TAB -->
|
| 243 |
+
<div class="panel" id="tab-about">
|
| 244 |
+
<h2>Technical Notes</h2>
|
| 245 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
| 246 |
+
<div class="sms-box">
|
| 247 |
+
<div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:6px;">Model</div>
|
| 248 |
+
<div style="font-size:12px;color:var(--muted);line-height:1.7;">
|
| 249 |
+
<strong style="color:var(--text)">LightGBM</strong> classifier for P(outage) + regressor for E[duration | outage].
|
| 250 |
+
Features: lagged load (1h, 2h, 24h, 48h), rolling stats, weather (temp, humidity, rain, wind),
|
| 251 |
+
temporal (hour, DOW, month, peak flags, rainy season). Training: 150-day window.
|
| 252 |
+
Evaluation: rolling 30-day held-out.
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
<div class="sms-box">
|
| 256 |
+
<div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:6px;">Performance</div>
|
| 257 |
+
<div style="font-size:12px;color:var(--muted);line-height:1.7;">
|
| 258 |
+
Brier score: <strong style="color:var(--green)">0.1756</strong> (naΓ―ve base rate = ~0.212)<br>
|
| 259 |
+
Duration MAE: <strong style="color:var(--green)">61.2 min</strong><br>
|
| 260 |
+
Avg lead time on true outages: <strong style="color:var(--green)">2.79h</strong><br>
|
| 261 |
+
Inference latency: <strong style="color:var(--green)"><300ms CPU</strong><br>
|
| 262 |
+
Retraining time: <strong style="color:var(--green)"><10 min</strong>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
<div class="sms-box">
|
| 266 |
+
<div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:6px;">Constraints Met</div>
|
| 267 |
+
<div style="font-size:12px;color:var(--muted);line-height:1.7;">
|
| 268 |
+
β
CPU-only Β· β
<10 min retrain Β· β
<300ms serve<br>
|
| 269 |
+
β
50KB static UI Β· β
Feature phone SMS digest<br>
|
| 270 |
+
β
Offline fallback protocol Β· β
Illiteracy adaptation<br>
|
| 271 |
+
β
3 business archetypes Β· β
Critical-before-luxury rule
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
<div class="sms-box">
|
| 275 |
+
<div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:6px;">Hardest Trade-off</div>
|
| 276 |
+
<div style="font-size:12px;color:var(--muted);line-height:1.7;">
|
| 277 |
+
Chose LightGBM over Prophet: faster retrain, handles irregular time steps,
|
| 278 |
+
natively supports tabular weather features. Trade-off: less interpretable
|
| 279 |
+
seasonality decomposition. Compensated with explicit hour/DOW/month features
|
| 280 |
+
and SHAP values available in eval notebook.
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
<footer>T2.3 Β· Grid Outage Forecaster + Appliance Prioritizer Β· AIMS KTT Hackathon 2026 Β· CPU-only Β· <50KB</footer>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
<script>
|
| 290 |
+
// ββ Embedded Data βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 291 |
+
const FORECAST = [{"hour_offset":0,"timestamp":"2024-06-29 00:00","hour":0,"p_outage":0.2708,"p_outage_low":0.1908,"p_outage_high":0.3508,"expected_duration_min":89.8,"risk_level":"HIGH"},{"hour_offset":1,"timestamp":"2024-06-29 01:00","hour":1,"p_outage":0.2554,"p_outage_low":0.1754,"p_outage_high":0.3354,"expected_duration_min":83.2,"risk_level":"HIGH"},{"hour_offset":2,"timestamp":"2024-06-29 02:00","hour":2,"p_outage":0.2169,"p_outage_low":0.1369,"p_outage_high":0.2969,"expected_duration_min":85.0,"risk_level":"MEDIUM"},{"hour_offset":3,"timestamp":"2024-06-29 03:00","hour":3,"p_outage":0.2554,"p_outage_low":0.1754,"p_outage_high":0.3354,"expected_duration_min":85.0,"risk_level":"HIGH"},{"hour_offset":4,"timestamp":"2024-06-29 04:00","hour":4,"p_outage":0.2602,"p_outage_low":0.1802,"p_outage_high":0.3402,"expected_duration_min":78.8,"risk_level":"HIGH"},{"hour_offset":5,"timestamp":"2024-06-29 05:00","hour":5,"p_outage":0.2503,"p_outage_low":0.1703,"p_outage_high":0.3303,"expected_duration_min":85.0,"risk_level":"HIGH"},{"hour_offset":6,"timestamp":"2024-06-29 06:00","hour":6,"p_outage":0.24,"p_outage_low":0.16,"p_outage_high":0.32,"expected_duration_min":83.2,"risk_level":"MEDIUM"},{"hour_offset":7,"timestamp":"2024-06-29 07:00","hour":7,"p_outage":0.2208,"p_outage_low":0.1408,"p_outage_high":0.3008,"expected_duration_min":78.5,"risk_level":"MEDIUM"},{"hour_offset":8,"timestamp":"2024-06-29 08:00","hour":8,"p_outage":0.2208,"p_outage_low":0.1408,"p_outage_high":0.3008,"expected_duration_min":78.5,"risk_level":"MEDIUM"},{"hour_offset":9,"timestamp":"2024-06-29 09:00","hour":9,"p_outage":0.198,"p_outage_low":0.118,"p_outage_high":0.278,"expected_duration_min":86.0,"risk_level":"MEDIUM"},{"hour_offset":10,"timestamp":"2024-06-29 10:00","hour":10,"p_outage":0.24,"p_outage_low":0.16,"p_outage_high":0.32,"expected_duration_min":71.3,"risk_level":"MEDIUM"},{"hour_offset":11,"timestamp":"2024-06-29 11:00","hour":11,"p_outage":0.2531,"p_outage_low":0.1731,"p_outage_high":0.3331,"expected_duration_min":73.1,"risk_level":"HIGH"},{"hour_offset":12,"timestamp":"2024-06-29 12:00","hour":12,"p_outage":0.2457,"p_outage_low":0.1657,"p_outage_high":0.3257,"expected_duration_min":76.9,"risk_level":"MEDIUM"},{"hour_offset":13,"timestamp":"2024-06-29 13:00","hour":13,"p_outage":0.263,"p_outage_low":0.183,"p_outage_high":0.343,"expected_duration_min":68.8,"risk_level":"HIGH"},{"hour_offset":14,"timestamp":"2024-06-29 14:00","hour":14,"p_outage":0.2582,"p_outage_low":0.1782,"p_outage_high":0.3382,"expected_duration_min":72.5,"risk_level":"HIGH"},{"hour_offset":15,"timestamp":"2024-06-29 15:00","hour":15,"p_outage":0.2194,"p_outage_low":0.1394,"p_outage_high":0.2994,"expected_duration_min":76.9,"risk_level":"MEDIUM"},{"hour_offset":16,"timestamp":"2024-06-29 16:00","hour":16,"p_outage":0.2688,"p_outage_low":0.1888,"p_outage_high":0.3488,"expected_duration_min":83.4,"risk_level":"HIGH"},{"hour_offset":17,"timestamp":"2024-06-29 17:00","hour":17,"p_outage":0.309,"p_outage_low":0.229,"p_outage_high":0.389,"expected_duration_min":84.6,"risk_level":"HIGH"},{"hour_offset":18,"timestamp":"2024-06-29 18:00","hour":18,"p_outage":0.3353,"p_outage_low":0.2553,"p_outage_high":0.4153,"expected_duration_min":84.6,"risk_level":"HIGH"},{"hour_offset":19,"timestamp":"2024-06-29 19:00","hour":19,"p_outage":0.3408,"p_outage_low":0.2608,"p_outage_high":0.4208,"expected_duration_min":76.1,"risk_level":"HIGH"},{"hour_offset":20,"timestamp":"2024-06-29 20:00","hour":20,"p_outage":0.3353,"p_outage_low":0.2553,"p_outage_high":0.4153,"expected_duration_min":99.4,"risk_level":"HIGH"},{"hour_offset":21,"timestamp":"2024-06-29 21:00","hour":21,"p_outage":0.3466,"p_outage_low":0.2666,"p_outage_high":0.4266,"expected_duration_min":100.6,"risk_level":"HIGH"},{"hour_offset":22,"timestamp":"2024-06-29 22:00","hour":22,"p_outage":0.2834,"p_outage_low":0.2034,"p_outage_high":0.3634,"expected_duration_min":102.5,"risk_level":"HIGH"},{"hour_offset":23,"timestamp":"2024-06-29 23:00","hour":23,"p_outage":0.2596,"p_outage_low":0.1796,"p_outage_high":0.3396,"expected_duration_min":106.9,"risk_level":"HIGH"}];
|
| 292 |
+
|
| 293 |
+
const PLANS = {
|
| 294 |
+
salon: {"business":"Beauty Salon (Kigali)","summary":{"total_revenue_plan_rwf":93850,"total_revenue_naive_rwf":101790,"revenue_saved_rwf":-7940,"disruption_penalty_avoided_rwf":20358,"net_benefit_rwf":12418,"hours_with_shed":24},"plan":[{"hour":0,"timestamp":"2024-06-29 00:00","risk_level":"HIGH","p_outage":0.2708,"expected_duration_min":89.8,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":1784},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1189},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":595},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0}]},{"hour":8,"timestamp":"2024-06-29 08:00","risk_level":"MEDIUM","p_outage":0.2208,"expected_duration_min":78.5,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":2133},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1422},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":711},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":285},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":142},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0}]},{"hour":18,"timestamp":"2024-06-29 18:00","risk_level":"HIGH","p_outage":0.3353,"expected_duration_min":84.6,"appliances":[{"name":"Hair Dryer (2Γ)","category":"critical","state":"ON","watts":2400,"revenue_rwf":1784},{"name":"Electric Clippers (3Γ)","category":"critical","state":"ON","watts":120,"revenue_rwf":1189},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":595},{"name":"Standing Fan","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0},{"name":"TV / Display","category":"comfort","state":"OFF","watts":0,"revenue_rwf":0},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0},{"name":"Neon Sign","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0}]}]},
|
| 295 |
+
cold_room: {"business":"Cold Room / Butchery","summary":{"total_revenue_plan_rwf":118000,"total_revenue_naive_rwf":125000,"revenue_saved_rwf":-7000,"disruption_penalty_avoided_rwf":25000,"net_benefit_rwf":18000,"hours_with_shed":16},"plan":[{"hour":6,"timestamp":"2024-06-29 06:00","risk_level":"MEDIUM","p_outage":0.24,"expected_duration_min":83.2,"appliances":[{"name":"Commercial Refrigerator","category":"critical","state":"ON","watts":350,"revenue_rwf":1850},{"name":"Water Pump","category":"critical","state":"ON","watts":750,"revenue_rwf":1100},{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":740},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":296},{"name":"TV / Display","category":"comfort","state":"ON","watts":150,"revenue_rwf":148}]}]},
|
| 296 |
+
tailor: {"business":"Tailor Shop","summary":{"total_revenue_plan_rwf":42000,"total_revenue_naive_rwf":48000,"revenue_saved_rwf":-6000,"disruption_penalty_avoided_rwf":9600,"net_benefit_rwf":3600,"hours_with_shed":14},"plan":[{"hour":9,"timestamp":"2024-06-29 09:00","risk_level":"MEDIUM","p_outage":0.198,"expected_duration_min":86,"appliances":[{"name":"LED Lights","category":"critical","state":"ON","watts":80,"revenue_rwf":590},{"name":"Standing Fan","category":"comfort","state":"ON","watts":75,"revenue_rwf":236},{"name":"Music System","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0},{"name":"TV / Display","category":"luxury","state":"OFF","watts":0,"revenue_rwf":0}]}]}
|
| 297 |
+
};
|
| 298 |
+
|
| 299 |
+
const SMS = [
|
| 300 |
+
"UMURIRO FORECAST 24H: Risk=HIGH at 0h,1h,3h. Shed: Standing+TV. Est.save: 12,418RWF. Stay alert!",
|
| 301 |
+
"PLAN: Turn OFF Standing+TV during risk hrs (0h,1h,3h). Keep dryer+clippers+lights ON. Generator ready?",
|
| 302 |
+
"If no signal by 13h, use YESTERDAY plan. Risk valid 6h. Call 0788-GRID for live update. Good business!"
|
| 303 |
+
];
|
| 304 |
+
|
| 305 |
+
// ββ Pre-build hrs[0..23] for each business ββββββββββββββββββββββββββββββββββββ
|
| 306 |
+
(function buildHrs() {
|
| 307 |
+
Object.values(PLANS).forEach(p => {
|
| 308 |
+
p.hrs = Array.from({length: 24}, (_, i) =>
|
| 309 |
+
p.plan.reduce((best, h) =>
|
| 310 |
+
Math.abs(h.hour - i) < Math.abs(best.hour - i) ? h : best, p.plan[0])
|
| 311 |
+
);
|
| 312 |
+
});
|
| 313 |
+
})();
|
| 314 |
+
|
| 315 |
+
// ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 316 |
+
let currentBiz = 'salon';
|
| 317 |
+
let selectedHour = 0;
|
| 318 |
+
|
| 319 |
+
// ββ Tab switching βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 320 |
+
function showTab(id, el) {
|
| 321 |
+
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
| 322 |
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 323 |
+
document.getElementById('tab-' + id).classList.add('active');
|
| 324 |
+
el.classList.add('active');
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
function switchBiz(biz, el) {
|
| 328 |
+
currentBiz = biz;
|
| 329 |
+
document.querySelectorAll('.biz-tab').forEach(t => t.classList.remove('active'));
|
| 330 |
+
el.classList.add('active');
|
| 331 |
+
renderPlan(selectedHour);
|
| 332 |
+
renderSummary();
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
function changeHour(delta) {
|
| 336 |
+
selectedHour = Math.max(0, Math.min(23, selectedHour + delta));
|
| 337 |
+
renderPlan(selectedHour);
|
| 338 |
+
document.querySelectorAll('.hour-cell').forEach((c, i) => {
|
| 339 |
+
c.classList.toggle('selected', i === selectedHour);
|
| 340 |
+
});
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
// ββ Chart (pure canvas, no library) ββββββββββββββββββββββββββββββββββββββββββ
|
| 344 |
+
function drawChart() {
|
| 345 |
+
const canvas = document.getElementById('forecastChart');
|
| 346 |
+
const dpr = window.devicePixelRatio || 1;
|
| 347 |
+
const W = canvas.parentElement.clientWidth - 32;
|
| 348 |
+
const H = 200;
|
| 349 |
+
canvas.width = W * dpr;
|
| 350 |
+
canvas.height = H * dpr;
|
| 351 |
+
canvas.style.width = W + 'px';
|
| 352 |
+
canvas.style.height = H + 'px';
|
| 353 |
+
const ctx = canvas.getContext('2d');
|
| 354 |
+
ctx.scale(dpr, dpr);
|
| 355 |
+
|
| 356 |
+
const pad = {l: 40, r: 10, t: 10, b: 30};
|
| 357 |
+
const cw = W - pad.l - pad.r;
|
| 358 |
+
const ch = H - pad.t - pad.b;
|
| 359 |
+
const n = FORECAST.length;
|
| 360 |
+
|
| 361 |
+
ctx.clearRect(0, 0, W, H);
|
| 362 |
+
|
| 363 |
+
// Grid lines
|
| 364 |
+
ctx.strokeStyle = '#2e3350';
|
| 365 |
+
ctx.lineWidth = 1;
|
| 366 |
+
[0, 0.1, 0.2, 0.3, 0.4, 0.5].forEach(v => {
|
| 367 |
+
const y = pad.t + ch - v * ch / 0.5;
|
| 368 |
+
if (y < pad.t) return;
|
| 369 |
+
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + cw, y); ctx.stroke();
|
| 370 |
+
ctx.fillStyle = '#8892b0'; ctx.font = '10px sans-serif'; ctx.textAlign = 'right';
|
| 371 |
+
ctx.fillText((v * 100).toFixed(0) + '%', pad.l - 4, y + 4);
|
| 372 |
+
});
|
| 373 |
+
|
| 374 |
+
// Hour labels
|
| 375 |
+
FORECAST.forEach((d, i) => {
|
| 376 |
+
if (i % 4 !== 0) return;
|
| 377 |
+
const x = pad.l + (i / (n - 1)) * cw;
|
| 378 |
+
ctx.fillStyle = '#8892b0'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
|
| 379 |
+
ctx.fillText(d.hour + 'h', x, H - 6);
|
| 380 |
+
});
|
| 381 |
+
|
| 382 |
+
// Risk background zones
|
| 383 |
+
FORECAST.forEach((d, i) => {
|
| 384 |
+
const x = pad.l + (i / n) * cw;
|
| 385 |
+
const bw = cw / n;
|
| 386 |
+
let col = d.risk_level === 'HIGH' ? 'rgba(239,68,68,.07)' :
|
| 387 |
+
d.risk_level === 'MEDIUM' ? 'rgba(249,115,22,.05)' : 'transparent';
|
| 388 |
+
ctx.fillStyle = col;
|
| 389 |
+
ctx.fillRect(x, pad.t, bw, ch);
|
| 390 |
+
});
|
| 391 |
+
|
| 392 |
+
const xOf = i => pad.l + (i / (n - 1)) * cw;
|
| 393 |
+
const yOf = v => pad.t + ch - (v / 0.5) * ch;
|
| 394 |
+
|
| 395 |
+
// Uncertainty band
|
| 396 |
+
ctx.beginPath();
|
| 397 |
+
FORECAST.forEach((d, i) => { i === 0 ? ctx.moveTo(xOf(i), yOf(d.p_outage_high)) : ctx.lineTo(xOf(i), yOf(d.p_outage_high)); });
|
| 398 |
+
FORECAST.slice().reverse().forEach((d, i) => ctx.lineTo(xOf(n - 1 - i), yOf(d.p_outage_low)));
|
| 399 |
+
ctx.closePath();
|
| 400 |
+
ctx.fillStyle = 'rgba(99,102,241,.18)';
|
| 401 |
+
ctx.fill();
|
| 402 |
+
|
| 403 |
+
// Main line
|
| 404 |
+
ctx.beginPath();
|
| 405 |
+
ctx.strokeStyle = '#6366f1'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round';
|
| 406 |
+
FORECAST.forEach((d, i) => { i === 0 ? ctx.moveTo(xOf(i), yOf(d.p_outage)) : ctx.lineTo(xOf(i), yOf(d.p_outage)); });
|
| 407 |
+
ctx.stroke();
|
| 408 |
+
|
| 409 |
+
// Threshold line at 0.25
|
| 410 |
+
ctx.beginPath();
|
| 411 |
+
ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
|
| 412 |
+
ctx.moveTo(pad.l, yOf(0.25)); ctx.lineTo(pad.l + cw, yOf(0.25)); ctx.stroke();
|
| 413 |
+
ctx.setLineDash([]);
|
| 414 |
+
ctx.fillStyle = '#ef4444'; ctx.font = '9px sans-serif'; ctx.textAlign = 'left';
|
| 415 |
+
ctx.fillText('HIGH', pad.l + 2, yOf(0.25) - 3);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// ββ Hour Grid βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 419 |
+
function renderHourGrid() {
|
| 420 |
+
const grid = document.getElementById('hourGrid');
|
| 421 |
+
grid.innerHTML = '';
|
| 422 |
+
FORECAST.forEach((d, i) => {
|
| 423 |
+
const cls = d.risk_level === 'HIGH' ? 'hc-high' : d.risk_level === 'MEDIUM' ? 'hc-medium' : 'hc-low';
|
| 424 |
+
const cell = document.createElement('div');
|
| 425 |
+
cell.className = 'hour-cell' + (i === selectedHour ? ' selected' : '');
|
| 426 |
+
cell.innerHTML = `<div class="hc-hour">${d.hour}h</div>
|
| 427 |
+
<div class="hc-prob ${cls}">${(d.p_outage * 100).toFixed(0)}%</div>
|
| 428 |
+
<div style="font-size:9px;margin-top:2px"><span class="badge badge-${d.risk_level.toLowerCase()}">${d.risk_level}</span></div>`;
|
| 429 |
+
cell.onclick = () => {
|
| 430 |
+
selectedHour = i;
|
| 431 |
+
document.querySelectorAll('.hour-cell').forEach((c, j) => c.classList.toggle('selected', j === i));
|
| 432 |
+
renderPlan(i);
|
| 433 |
+
showTab('plan', document.querySelector('.tab:nth-child(2)'));
|
| 434 |
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 435 |
+
document.querySelectorAll('.tab')[1].classList.add('active');
|
| 436 |
+
};
|
| 437 |
+
grid.appendChild(cell);
|
| 438 |
+
});
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
// ββ Summary Bar βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 442 |
+
function renderSummary() {
|
| 443 |
+
const p = PLANS[currentBiz] || PLANS.salon;
|
| 444 |
+
const s = p.summary;
|
| 445 |
+
const highH = FORECAST.filter(f => f.risk_level === 'HIGH').length;
|
| 446 |
+
document.getElementById('summaryBar').innerHTML = `
|
| 447 |
+
<div class="sum-card"><div class="sum-val text-green">${(s.net_benefit_rwf/1000).toFixed(1)}K</div><div class="sum-lbl">Net Benefit (RWF)</div></div>
|
| 448 |
+
<div class="sum-card"><div class="sum-val text-red">${highH}</div><div class="sum-lbl">HIGH Risk Hours</div></div>
|
| 449 |
+
<div class="sum-card"><div class="sum-val text-orange">${s.hours_with_shed}</div><div class="sum-lbl">Hours with Shed</div></div>
|
| 450 |
+
<div class="sum-card"><div class="sum-val text-blue">${(s.total_revenue_plan_rwf/1000).toFixed(0)}K</div><div class="sum-lbl">Expected Rev (RWF)</div></div>`;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
// ββ Appliance Plan ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 454 |
+
function renderPlan(hourIdx) {
|
| 455 |
+
const p = PLANS[currentBiz] || PLANS.salon;
|
| 456 |
+
const hData = p.hrs[hourIdx]; // direct read β no reduce needed
|
| 457 |
+
const fc = FORECAST[hourIdx];
|
| 458 |
+
|
| 459 |
+
document.getElementById('planHourLabel').innerHTML =
|
| 460 |
+
`Hour ${hourIdx} Β· ${fc.timestamp.split(' ')[1]} Β·
|
| 461 |
+
<span class="badge badge-${fc.risk_level.toLowerCase()}">${fc.risk_level}</span>
|
| 462 |
+
P(outage)=${(fc.p_outage*100).toFixed(1)}% Exp.dur=${fc.expected_duration_min.toFixed(0)}min`;
|
| 463 |
+
document.getElementById('planHourNum').textContent = 'Hour ' + hourIdx;
|
| 464 |
+
|
| 465 |
+
const appliances = hData.appliances || [];
|
| 466 |
+
document.getElementById('applianceGrid').innerHTML = appliances.map(ap => `
|
| 467 |
+
<div class="ap-card${ap.state === 'OFF' ? ' off' : ''}">
|
| 468 |
+
<div class="ap-left">
|
| 469 |
+
<div class="ap-name">${ap.name}</div>
|
| 470 |
+
<div class="ap-meta">
|
| 471 |
+
<span class="badge badge-${ap.category}">${ap.category}</span>
|
| 472 |
+
<span class="badge badge-${ap.state.toLowerCase()}">${ap.state}</span>
|
| 473 |
+
</div>
|
| 474 |
+
${ap.shed_reason ? `<div style="font-size:10px;color:#9ca3af;margin-top:2px">${ap.shed_reason}</div>` : ''}
|
| 475 |
+
</div>
|
| 476 |
+
<div class="ap-right">
|
| 477 |
+
<div class="ap-watts">${ap.watts}W</div>
|
| 478 |
+
<div class="ap-rev">${ap.state === 'ON' ? ap.revenue_rwf.toLocaleString() + ' RWF/h' : 'β'}</div>
|
| 479 |
+
</div>
|
| 480 |
+
</div>`).join('');
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
// ββ SMS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 484 |
+
function renderSMS() {
|
| 485 |
+
document.getElementById('smsBox').innerHTML = SMS.map((msg, i) => `
|
| 486 |
+
<div class="sms-box">
|
| 487 |
+
<div class="sms-header">
|
| 488 |
+
<span class="sms-num">SMS ${i+1}/3</span>
|
| 489 |
+
<span class="sms-chars">${msg.length}/160 chars</span>
|
| 490 |
+
</div>
|
| 491 |
+
<div class="sms-text">${msg}</div>
|
| 492 |
+
</div>`).join('');
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
// ββ Offline detection βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 496 |
+
function checkOffline() {
|
| 497 |
+
if (!navigator.onLine) {
|
| 498 |
+
document.getElementById('offlineBanner').classList.add('show');
|
| 499 |
+
document.getElementById('staleTime').textContent = new Date().toLocaleTimeString();
|
| 500 |
+
}
|
| 501 |
+
}
|
| 502 |
+
window.addEventListener('offline', checkOffline);
|
| 503 |
+
|
| 504 |
+
// ββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 505 |
+
window.addEventListener('load', () => {
|
| 506 |
+
drawChart();
|
| 507 |
+
renderHourGrid();
|
| 508 |
+
renderPlan(0);
|
| 509 |
+
renderSummary();
|
| 510 |
+
renderSMS();
|
| 511 |
+
checkOffline();
|
| 512 |
+
window.addEventListener('resize', drawChart);
|
| 513 |
+
});
|
| 514 |
+
</script>
|
| 515 |
+
</body>
|
| 516 |
+
</html>
|
src/prioritizer.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
T2.3 Β· prioritizer.py
|
| 3 |
+
Appliance load-shedding plan generator.
|
| 4 |
+
Given a 24h forecast and a business's appliance list,
|
| 5 |
+
outputs per-appliance, per-hour ON/OFF plan maximizing
|
| 6 |
+
expected revenue under the 'drop luxury before critical' rule.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
from prioritizer import plan, load_data
|
| 10 |
+
appliances, businesses = load_data()
|
| 11 |
+
forecast = [...] # from forecaster.py
|
| 12 |
+
result = plan(forecast, appliances, business_id="salon")
|
| 13 |
+
print(result)
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
# Category priority order (lower = shed first / shed last... no, drop luxury FIRST)
|
| 21 |
+
# Shedding priority (1 = shed first): luxury > comfort > critical
|
| 22 |
+
SHED_ORDER = {"luxury": 1, "comfort": 2, "critical": 3}
|
| 23 |
+
CATEGORY_REVENUE_WEIGHT = {"critical": 1.0, "comfort": 0.6, "luxury": 0.2}
|
| 24 |
+
|
| 25 |
+
# Outage risk thresholds for shed depth
|
| 26 |
+
RISK_SHED_FRACTION = {
|
| 27 |
+
"LOW": 0.0, # no shedding
|
| 28 |
+
"MEDIUM": 0.33, # shed luxury
|
| 29 |
+
"HIGH": 0.66, # shed luxury + comfort
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_data(appliances_path="appliances.json", businesses_path="businesses.json"):
|
| 34 |
+
with open(appliances_path) as f:
|
| 35 |
+
appliances = json.load(f)
|
| 36 |
+
with open(businesses_path) as f:
|
| 37 |
+
businesses = json.load(f)
|
| 38 |
+
return appliances, businesses
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def get_business_appliances(appliances: list, business: dict) -> list:
|
| 42 |
+
"""Filter appliances to those used by this business."""
|
| 43 |
+
ap_map = {a["id"]: a for a in appliances}
|
| 44 |
+
return [ap_map[aid] for aid in business["appliance_ids"] if aid in ap_map]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def plan(forecast: list[dict], appliances: list, business_id: str = "salon",
|
| 48 |
+
businesses_path: str = "businesses.json") -> dict:
|
| 49 |
+
"""
|
| 50 |
+
Core planning function.
|
| 51 |
+
|
| 52 |
+
Algorithm:
|
| 53 |
+
1. For each hour, determine outage risk level from forecast.
|
| 54 |
+
2. Sort appliances by shed priority (luxury first, critical last).
|
| 55 |
+
3. Apply shed depth based on risk: LOW=none, MEDIUM=shed luxury,
|
| 56 |
+
HIGH=shed luxury+comfort. Critical never shed unless P>0.5.
|
| 57 |
+
4. Within each category, break ties by lowest revenue-per-watt (shed cheapest first).
|
| 58 |
+
5. Calculate expected revenue saved vs naΓ―ve full-on.
|
| 59 |
+
|
| 60 |
+
Returns dict with 24-hour plan per appliance + summary stats.
|
| 61 |
+
"""
|
| 62 |
+
# Load business
|
| 63 |
+
with open(businesses_path) as f:
|
| 64 |
+
businesses = json.load(f)
|
| 65 |
+
biz_map = {b["id"]: b for b in businesses}
|
| 66 |
+
business = biz_map[business_id]
|
| 67 |
+
biz_appliances = get_business_appliances(appliances, business)
|
| 68 |
+
|
| 69 |
+
# Sort appliances: shed luxury first, then comfort, then critical
|
| 70 |
+
# Within category: sort by revenue desc (protect highest revenue first)
|
| 71 |
+
def shed_sort_key(ap):
|
| 72 |
+
cat_priority = SHED_ORDER[ap["category"]] # luxury=1 shed first
|
| 73 |
+
rev = ap["revenue_if_running_rwf_per_h"]
|
| 74 |
+
return (cat_priority, rev) # shed low-revenue luxury first
|
| 75 |
+
|
| 76 |
+
sorted_appliances = sorted(biz_appliances, key=shed_sort_key)
|
| 77 |
+
|
| 78 |
+
hourly_plan = []
|
| 79 |
+
total_revenue_plan = 0
|
| 80 |
+
total_revenue_naive = 0
|
| 81 |
+
|
| 82 |
+
for hour_data in forecast:
|
| 83 |
+
h = hour_data["hour"]
|
| 84 |
+
p_out = hour_data["p_outage"]
|
| 85 |
+
risk = hour_data["risk_level"]
|
| 86 |
+
exp_dur = hour_data["expected_duration_min"]
|
| 87 |
+
|
| 88 |
+
# Fraction of hour expected to be without power
|
| 89 |
+
frac_lost = (exp_dur / 60.0) * p_out
|
| 90 |
+
frac_lost = min(frac_lost, 1.0)
|
| 91 |
+
|
| 92 |
+
# Determine how many categories to shed
|
| 93 |
+
# HIGH risk: shed luxury + comfort (keep critical)
|
| 94 |
+
# MEDIUM risk: shed luxury only
|
| 95 |
+
# LOW risk: keep all on
|
| 96 |
+
# Exception: if P(outage) > 0.5, even critical gets load-managed
|
| 97 |
+
categories_to_shed = set()
|
| 98 |
+
if risk == "HIGH":
|
| 99 |
+
categories_to_shed = {"luxury", "comfort"}
|
| 100 |
+
elif risk == "MEDIUM":
|
| 101 |
+
categories_to_shed = {"luxury"}
|
| 102 |
+
if p_out > 0.50:
|
| 103 |
+
categories_to_shed.add("critical")
|
| 104 |
+
|
| 105 |
+
appliance_states = []
|
| 106 |
+
hour_revenue_plan = 0
|
| 107 |
+
hour_revenue_naive = 0
|
| 108 |
+
|
| 109 |
+
for ap in biz_appliances:
|
| 110 |
+
# Check business peak hours β don't shed critical during peak
|
| 111 |
+
is_peak = h in business.get("peak_hours", [])
|
| 112 |
+
if ap["category"] == "critical" and is_peak:
|
| 113 |
+
# Never shed critical during peak hours regardless
|
| 114 |
+
state = "ON"
|
| 115 |
+
elif ap["category"] in categories_to_shed:
|
| 116 |
+
state = "OFF"
|
| 117 |
+
else:
|
| 118 |
+
state = "ON"
|
| 119 |
+
|
| 120 |
+
# Revenue calculation
|
| 121 |
+
base_rev = ap["revenue_if_running_rwf_per_h"]
|
| 122 |
+
naive_rev = base_rev * (1 - frac_lost) # naive: stays on, loses revenue during outage
|
| 123 |
+
plan_rev = base_rev if state == "ON" else 0 # plan: if OFF we save the outage disruption
|
| 124 |
+
|
| 125 |
+
# If ON and outage still happens, we lose some revenue regardless
|
| 126 |
+
if state == "ON":
|
| 127 |
+
plan_rev = base_rev * (1 - frac_lost)
|
| 128 |
+
|
| 129 |
+
hour_revenue_plan += plan_rev
|
| 130 |
+
hour_revenue_naive += naive_rev
|
| 131 |
+
|
| 132 |
+
appliance_states.append({
|
| 133 |
+
"appliance_id": ap["id"],
|
| 134 |
+
"name": ap["name"],
|
| 135 |
+
"category": ap["category"],
|
| 136 |
+
"state": state,
|
| 137 |
+
"watts": ap["watts_avg"] if state == "ON" else 0,
|
| 138 |
+
"revenue_rwf": round(plan_rev, 0),
|
| 139 |
+
"shed_reason": f"Risk={risk}, P={p_out:.2f}" if state == "OFF" else None,
|
| 140 |
+
})
|
| 141 |
+
|
| 142 |
+
total_revenue_plan += hour_revenue_plan
|
| 143 |
+
total_revenue_naive += hour_revenue_naive
|
| 144 |
+
|
| 145 |
+
hourly_plan.append({
|
| 146 |
+
"hour_offset": hour_data["hour_offset"],
|
| 147 |
+
"timestamp": hour_data["timestamp"],
|
| 148 |
+
"hour": h,
|
| 149 |
+
"p_outage": p_out,
|
| 150 |
+
"risk_level": risk,
|
| 151 |
+
"expected_duration_min": exp_dur,
|
| 152 |
+
"appliances": appliance_states,
|
| 153 |
+
"hour_revenue_plan_rwf": round(hour_revenue_plan, 0),
|
| 154 |
+
"hour_revenue_naive_rwf": round(hour_revenue_naive, 0),
|
| 155 |
+
})
|
| 156 |
+
|
| 157 |
+
revenue_saved = total_revenue_plan - total_revenue_naive
|
| 158 |
+
# In HIGH risk periods, turning off luxury/comfort means we don't waste startup costs
|
| 159 |
+
# but main saving is avoiding the disruption penalty we model as a 20% recovery cost
|
| 160 |
+
disruption_penalty = total_revenue_naive * 0.20
|
| 161 |
+
net_benefit = revenue_saved + disruption_penalty
|
| 162 |
+
|
| 163 |
+
return {
|
| 164 |
+
"business": business["name"],
|
| 165 |
+
"business_id": business_id,
|
| 166 |
+
"plan": hourly_plan,
|
| 167 |
+
"summary": {
|
| 168 |
+
"total_revenue_plan_rwf": round(total_revenue_plan, 0),
|
| 169 |
+
"total_revenue_naive_rwf": round(total_revenue_naive, 0),
|
| 170 |
+
"revenue_saved_rwf": round(revenue_saved, 0),
|
| 171 |
+
"disruption_penalty_avoided_rwf": round(disruption_penalty, 0),
|
| 172 |
+
"net_benefit_rwf": round(net_benefit, 0),
|
| 173 |
+
"hours_with_shed": sum(
|
| 174 |
+
1 for h in hourly_plan
|
| 175 |
+
if any(a["state"] == "OFF" for a in h["appliances"])
|
| 176 |
+
),
|
| 177 |
+
},
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def format_digest(plan_result: dict, forecast: list) -> list[str]:
|
| 182 |
+
"""
|
| 183 |
+
Generate 3 SMS messages (max 160 chars each) for the morning digest.
|
| 184 |
+
Designed for feature phone delivery.
|
| 185 |
+
"""
|
| 186 |
+
biz = plan_result["business"]
|
| 187 |
+
summary = plan_result["summary"]
|
| 188 |
+
hourly = plan_result["plan"]
|
| 189 |
+
|
| 190 |
+
# Find highest-risk hours
|
| 191 |
+
high_risk_hours = [h for h in hourly if h["risk_level"] == "HIGH"]
|
| 192 |
+
med_risk_hours = [h for h in hourly if h["risk_level"] == "MEDIUM"]
|
| 193 |
+
|
| 194 |
+
if high_risk_hours:
|
| 195 |
+
risk_times = ",".join([str(h["hour"]) + "h" for h in high_risk_hours[:3]])
|
| 196 |
+
risk_word = "HIGH"
|
| 197 |
+
elif med_risk_hours:
|
| 198 |
+
risk_times = ",".join([str(h["hour"]) + "h" for h in med_risk_hours[:3]])
|
| 199 |
+
risk_word = "MED"
|
| 200 |
+
else:
|
| 201 |
+
risk_times = "none"
|
| 202 |
+
risk_word = "LOW"
|
| 203 |
+
|
| 204 |
+
# Appliances to shed
|
| 205 |
+
shed_hours = [h for h in hourly if any(a["state"] == "OFF" for a in h["appliances"])]
|
| 206 |
+
if shed_hours:
|
| 207 |
+
sample_hour = shed_hours[0]
|
| 208 |
+
shed_names = [a["name"].split()[0] for a in sample_hour["appliances"]
|
| 209 |
+
if a["state"] == "OFF"][:2]
|
| 210 |
+
shed_str = "+".join(shed_names)
|
| 211 |
+
else:
|
| 212 |
+
shed_str = "none"
|
| 213 |
+
|
| 214 |
+
net = int(summary["net_benefit_rwf"])
|
| 215 |
+
saved_str = f"{net:,}RWF"
|
| 216 |
+
|
| 217 |
+
sms1 = f"UMURIRO FORECAST 24H: Risk={risk_word} at {risk_times}. Shed: {shed_str}. Est.save: {saved_str}. Stay alert!"
|
| 218 |
+
sms2 = f"PLAN: Turn OFF {shed_str} during risk hrs ({risk_times}). Keep dryer+clippers+lights ON. Generator ready?"
|
| 219 |
+
sms3 = f"If no signal by 13h, use YESTERDAY plan. Risk valid 6h. Call 0788-GRID for live update. Good business!"
|
| 220 |
+
|
| 221 |
+
# Enforce 160 char limit
|
| 222 |
+
sms1 = sms1[:160]
|
| 223 |
+
sms2 = sms2[:160]
|
| 224 |
+
sms3 = sms3[:160]
|
| 225 |
+
|
| 226 |
+
return [sms1, sms2, sms3]
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def print_plan(plan_result: dict):
|
| 230 |
+
"""Pretty-print the 24h plan to terminal."""
|
| 231 |
+
print(f"\n{'='*70}")
|
| 232 |
+
print(f" LOAD-SHEDDING PLAN β {plan_result['business']}")
|
| 233 |
+
print(f"{'='*70}")
|
| 234 |
+
print(f"{'Hour':>5} {'Time':>12} {'Risk':>6} {'P(out)':>7} | Appliances OFF")
|
| 235 |
+
print("-" * 70)
|
| 236 |
+
for h in plan_result["plan"]:
|
| 237 |
+
off = [a["name"][:12] for a in h["appliances"] if a["state"] == "OFF"]
|
| 238 |
+
off_str = ", ".join(off) if off else "β"
|
| 239 |
+
print(f"{h['hour']:>5} {h['timestamp'][11:]:>12} {h['risk_level']:>6} "
|
| 240 |
+
f"{h['p_outage']:>7.3f} | {off_str}")
|
| 241 |
+
print("-" * 70)
|
| 242 |
+
s = plan_result["summary"]
|
| 243 |
+
print(f" Net benefit vs naΓ―ve: {s['net_benefit_rwf']:,.0f} RWF")
|
| 244 |
+
print(f" Revenue (plan): {s['total_revenue_plan_rwf']:,.0f} RWF")
|
| 245 |
+
print(f" Shed hours: {s['hours_with_shed']}/24")
|
| 246 |
+
print(f"{'='*70}\n")
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
if __name__ == "__main__":
|
| 250 |
+
import sys
|
| 251 |
+
from forecaster import Forecaster
|
| 252 |
+
|
| 253 |
+
business_id = sys.argv[1] if len(sys.argv) > 1 else "salon"
|
| 254 |
+
|
| 255 |
+
print(f"Fitting forecaster...")
|
| 256 |
+
fc = Forecaster().fit("grid_history.csv")
|
| 257 |
+
forecast = fc.predict_next_24h()
|
| 258 |
+
|
| 259 |
+
appliances, businesses = load_data()
|
| 260 |
+
|
| 261 |
+
print(f"\nGenerating plan for: {business_id}")
|
| 262 |
+
result = plan(forecast, appliances, business_id=business_id)
|
| 263 |
+
print_plan(result)
|
| 264 |
+
|
| 265 |
+
# SMS digest
|
| 266 |
+
sms_msgs = format_digest(result, forecast)
|
| 267 |
+
print("π± Morning SMS Digest (3Γ160 chars):")
|
| 268 |
+
for i, msg in enumerate(sms_msgs, 1):
|
| 269 |
+
print(f" SMS {i} ({len(msg)} chars): {msg}")
|
src/process_log.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# process_log.md Β· T2.3 Β· Grid Outage Forecaster + Appliance Prioritizer
|
| 2 |
+
|
| 3 |
+
**Candidate:** Nathnael Dereje Mengistu
|
| 4 |
+
**Challenge:** T2.3 Β· Grid Outage Forecaster + Appliance Prioritizer
|
| 5 |
+
**Date:** 2026-04-23
|
| 6 |
+
**Total build time:** ~3.5 hours
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## Hour-by-Hour Timeline
|
| 11 |
+
|
| 12 |
+
| Time | Activity |
|
| 13 |
+
|------|----------|
|
| 14 |
+
| 0:00β0:20 | Read both PDFs end-to-end. Identified the 4 tasks, 6 deliverables, scoring weights. Noted Product & Business = 20% weight β equal to technical. Decided to frontload data + model. |
|
| 15 |
+
| 0:20β0:45 | Wrote `generate_data.py`. Synthetic data: logistic outage model, LogNormal duration, dual-peak load, rainy season noise. Ran and verified: 4,320 rows, 12.2% outage rate. |
|
| 16 |
+
| 0:45β1:20 | Wrote `forecaster.py`. Chose LightGBM over Prophet (see hardest decision). Built feature engineering: 4 load lags, rolling stats, weather, temporal flags. Fit + confirmed forecast runs in <300ms. |
|
| 17 |
+
| 1:20β1:50 | Wrote `prioritizer.py`. Implemented `plan()` function. Tested critical-before-luxury rule, peak-hour protection. Confirmed correct appliance shedding across all 3 business archetypes. |
|
| 18 |
+
| 1:50β2:10 | Ran rolling 30-day evaluation (`--eval`). Results: Brier=0.1756, MAE=61.2min, lead_time=2.79h. Documented in eval notebook. |
|
| 19 |
+
| 2:10β2:45 | Built `lite_ui.html`. Pure canvas chart (no JS library = smaller file). Hour grid, appliance plan, SMS tab, About tab. Business switcher. Offline banner. Verified <50KB (actual: 33KB). |
|
| 20 |
+
| 2:45β3:10 | Wrote `digest_spec.md`. Filled in all 4 sections: SMS design, offline protocol, LED relay adaptation, revenue calculation with real RWF numbers. |
|
| 21 |
+
| 3:10β3:30 | Wrote eval notebook (`eval.ipynb`), README, SIGNED.md. Verified all files present. |
|
| 22 |
+
| 3:30β3:45 | Final review: tested `python prioritizer.py salon` terminal output, verified SMS <160 chars, checked README runs in 2 commands. |
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## LLM / Tool Use Declaration
|
| 27 |
+
|
| 28 |
+
**Tool used:** Claude Sonnet 4.6 (claude.ai)
|
| 29 |
+
**Role:** Code scaffolding, design review, artifact drafting
|
| 30 |
+
|
| 31 |
+
**How I used it:**
|
| 32 |
+
- Described the challenge and asked Claude to help scaffold all files simultaneously
|
| 33 |
+
- Reviewed and edited all generated code before running β fixed the lag indexing in `build_features()`, adjusted shed threshold logic in `prioritizer.py`, redesigned the chart rendering
|
| 34 |
+
- The Product & Business thinking (LED board choice, RWF numbers, 6-hour staleness rule, Rwanda-specific context) is mine β I drew on my experience with SME operations across East Africa
|
| 35 |
+
|
| 36 |
+
**Three sample prompts I actually sent:**
|
| 37 |
+
|
| 38 |
+
1. *"Build generate_data.py for the AIMS KTT T2.3 challenge. The spec says: logistic outage model with sigmoid(a0 + a1*load_lag1 + a2*rain + a3*hour_of_day), LogNormal duration mean=90min sigma=0.6, dual-peak load morning+evening, weekly seasonality, rainy season noise. Output grid_history.csv with columns: timestamp, load_mw, temp_c, humidity, wind_ms, rain_mm, outage, duration_min. Also generate appliances.json and businesses.json matching the 10 appliances and 3 archetypes in the brief."*
|
| 39 |
+
|
| 40 |
+
2. *"Now write forecaster.py using LightGBM. Features: load lags at 1h, 2h, 24h, 48h; rolling mean and std; weather cols; hour, DOW, month, is_weekend, is_peak_morning, is_peak_evening, is_rainy_season; outage_lag1, outage_roll6_sum. Fit a classifier for P(outage) and a regressor for E[duration|outage]. predict_next_24h() returns list of 24 dicts including p_outage, p_outage_low, p_outage_high, expected_duration_min, risk_level. Include --eval flag for rolling 30-day Brier + MAE."*
|
| 41 |
+
|
| 42 |
+
3. *"Write prioritizer.py with a plan() function. Core rule: shed luxury first, then comfort, never critical unless P>0.5. Within each category, shed lowest-revenue appliances first. Protect critical appliances during peak hours regardless of risk. Calculate expected revenue saved vs naive full-on operation. Include format_digest() generating 3 SMS messages max 160 chars each, mixing Kinyarwanda and English."*
|
| 43 |
+
|
| 44 |
+
**One prompt I discarded and why:**
|
| 45 |
+
|
| 46 |
+
I drafted a prompt asking Claude to add a neighbour-signal stretch goal (crowd-reported outages re-ranking forecasts). I discarded it because: (a) it would have pushed total time past 4h, (b) a clean working baseline with solid Product & Business artifacts scores better than a half-working stretch feature. The brief itself warns: "A clean, simple, correct baseline always beats a half-working 'production' solution."
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## Hardest Decision
|
| 51 |
+
|
| 52 |
+
**LightGBM vs Prophet for the forecasting backbone.**
|
| 53 |
+
|
| 54 |
+
Prophet was the first option listed in the brief and is more interpretable (explicit seasonality decomposition, trend + Fourier terms). LightGBM is faster to retrain, handles tabular weather features natively without special regressors, and easily produces calibrated probabilities via `predict_proba`. The trade-off: Prophet would have given me a cleaner uncertainty band from its built-in posterior sampling, and the decomposition would be easier to explain in the live defense ("here is the weekly component, here is the rainy-season component").
|
| 55 |
+
|
| 56 |
+
I chose LightGBM because: (1) the 180-day synthetic dataset has structured patterns that tabular methods handle well, (2) I wanted the weather features (rain, humidity) as first-class inputs without Prophet's awkward `add_regressor()` API, (3) inference speed β <300ms is a hard constraint. The risk I accepted: I need to explain the feature importances clearly in the live defense, and the uncertainty band is a heuristic (Β±0.08) rather than a proper posterior interval. If I had more time, I would fit isotonic regression on validation probabilities to get a calibrated confidence interval instead.
|
src/requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.32.0
|
| 2 |
+
plotly>=5.19.0
|
| 3 |
+
pandas>=2.0.0
|