KB-Infinity-Tech commited on
Commit
099d46e
Β·
verified Β·
1 Parent(s): 2acbaf1

Upload 18 files

Browse files
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> &nbsp;Β·&nbsp; {fc['timestamp'].split()[1]}
324
+ &nbsp;&nbsp;<span class='badge badge-{risk.lower()}'>{risk}</span>
325
+ &nbsp;&nbsp;P(outage) = <b>{fc['p_outage']*100:.1f}%</b>
326
+ &nbsp;&nbsp;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 &gt; 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 &nbsp;Β·&nbsp; 🟑 YELLOW = shed if load high &nbsp;Β·&nbsp; πŸ”΄ 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'>&lt;300ms CPU</b><br>
441
+ Retraining time: <b style='color:#22c55e'>&lt;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 &nbsp;Β·&nbsp; βœ… &lt;10 min retrain &nbsp;Β·&nbsp; βœ… &lt;300ms serve<br>
451
+ βœ… Feature phone SMS digest &nbsp;Β·&nbsp; βœ… Offline fallback protocol<br>
452
+ βœ… Illiteracy adaptation &nbsp;Β·&nbsp; βœ… 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 &lt;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 &gt;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 &gt; 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)">&lt;300ms CPU</strong><br>
262
+ Retraining time: <strong style="color:var(--green)">&lt;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 Β· βœ… &lt;10 min retrain Β· βœ… &lt;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 Β· &lt;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} &nbsp;Β·&nbsp; ${fc.timestamp.split(' ')[1]} &nbsp;Β·&nbsp;
453
+ <span class="badge badge-${fc.risk_level.toLowerCase()}">${fc.risk_level}</span> &nbsp;
454
+ P(outage)=${(fc.p_outage*100).toFixed(1)}% &nbsp; 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 &lt;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 &gt;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 &gt; 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)">&lt;300ms CPU</strong><br>
262
+ Retraining time: <strong style="color:var(--green)">&lt;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 Β· βœ… &lt;10 min retrain Β· βœ… &lt;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 Β· &lt;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} &nbsp;Β·&nbsp; ${fc.timestamp.split(' ')[1]} &nbsp;Β·&nbsp;
461
+ <span class="badge badge-${fc.risk_level.toLowerCase()}">${fc.risk_level}</span> &nbsp;
462
+ P(outage)=${(fc.p_outage*100).toFixed(1)}% &nbsp; 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