File size: 10,131 Bytes
099d46e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
"""
T2.3 · prioritizer.py
Appliance load-shedding plan generator.
Given a 24h forecast and a business's appliance list,
outputs per-appliance, per-hour ON/OFF plan maximizing
expected revenue under the 'drop luxury before critical' rule.

Usage:
    from prioritizer import plan, load_data
    appliances, businesses = load_data()
    forecast = [...]  # from forecaster.py
    result = plan(forecast, appliances, business_id="salon")
    print(result)
"""

import json
from pathlib import Path
from typing import Optional

# Category priority order (lower = shed first / shed last... no, drop luxury FIRST)
# Shedding priority (1 = shed first): luxury > comfort > critical
SHED_ORDER = {"luxury": 1, "comfort": 2, "critical": 3}
CATEGORY_REVENUE_WEIGHT = {"critical": 1.0, "comfort": 0.6, "luxury": 0.2}

# Outage risk thresholds for shed depth
RISK_SHED_FRACTION = {
    "LOW":    0.0,   # no shedding
    "MEDIUM": 0.33,  # shed luxury
    "HIGH":   0.66,  # shed luxury + comfort
}


def load_data(appliances_path="appliances.json", businesses_path="businesses.json"):
    with open(appliances_path) as f:
        appliances = json.load(f)
    with open(businesses_path) as f:
        businesses = json.load(f)
    return appliances, businesses


def get_business_appliances(appliances: list, business: dict) -> list:
    """Filter appliances to those used by this business."""
    ap_map = {a["id"]: a for a in appliances}
    return [ap_map[aid] for aid in business["appliance_ids"] if aid in ap_map]


def plan(forecast: list[dict], appliances: list, business_id: str = "salon",
         businesses_path: str = "businesses.json") -> dict:
    """
    Core planning function.

    Algorithm:
    1. For each hour, determine outage risk level from forecast.
    2. Sort appliances by shed priority (luxury first, critical last).
    3. Apply shed depth based on risk: LOW=none, MEDIUM=shed luxury,
       HIGH=shed luxury+comfort. Critical never shed unless P>0.5.
    4. Within each category, break ties by lowest revenue-per-watt (shed cheapest first).
    5. Calculate expected revenue saved vs naïve full-on.

    Returns dict with 24-hour plan per appliance + summary stats.
    """
    # Load business
    with open(businesses_path) as f:
        businesses = json.load(f)
    biz_map = {b["id"]: b for b in businesses}
    business = biz_map[business_id]
    biz_appliances = get_business_appliances(appliances, business)

    # Sort appliances: shed luxury first, then comfort, then critical
    # Within category: sort by revenue desc (protect highest revenue first)
    def shed_sort_key(ap):
        cat_priority = SHED_ORDER[ap["category"]]  # luxury=1 shed first
        rev = ap["revenue_if_running_rwf_per_h"]
        return (cat_priority, rev)  # shed low-revenue luxury first

    sorted_appliances = sorted(biz_appliances, key=shed_sort_key)

    hourly_plan = []
    total_revenue_plan = 0
    total_revenue_naive = 0

    for hour_data in forecast:
        h = hour_data["hour"]
        p_out = hour_data["p_outage"]
        risk = hour_data["risk_level"]
        exp_dur = hour_data["expected_duration_min"]

        # Fraction of hour expected to be without power
        frac_lost = (exp_dur / 60.0) * p_out
        frac_lost = min(frac_lost, 1.0)

        # Determine how many categories to shed
        # HIGH risk: shed luxury + comfort (keep critical)
        # MEDIUM risk: shed luxury only
        # LOW risk: keep all on
        # Exception: if P(outage) > 0.5, even critical gets load-managed
        categories_to_shed = set()
        if risk == "HIGH":
            categories_to_shed = {"luxury", "comfort"}
        elif risk == "MEDIUM":
            categories_to_shed = {"luxury"}
        if p_out > 0.50:
            categories_to_shed.add("critical")

        appliance_states = []
        hour_revenue_plan = 0
        hour_revenue_naive = 0

        for ap in biz_appliances:
            # Check business peak hours — don't shed critical during peak
            is_peak = h in business.get("peak_hours", [])
            if ap["category"] == "critical" and is_peak:
                # Never shed critical during peak hours regardless
                state = "ON"
            elif ap["category"] in categories_to_shed:
                state = "OFF"
            else:
                state = "ON"

            # Revenue calculation
            base_rev = ap["revenue_if_running_rwf_per_h"]
            naive_rev = base_rev * (1 - frac_lost)  # naive: stays on, loses revenue during outage
            plan_rev = base_rev if state == "ON" else 0  # plan: if OFF we save the outage disruption

            # If ON and outage still happens, we lose some revenue regardless
            if state == "ON":
                plan_rev = base_rev * (1 - frac_lost)

            hour_revenue_plan += plan_rev
            hour_revenue_naive += naive_rev

            appliance_states.append({
                "appliance_id": ap["id"],
                "name": ap["name"],
                "category": ap["category"],
                "state": state,
                "watts": ap["watts_avg"] if state == "ON" else 0,
                "revenue_rwf": round(plan_rev, 0),
                "shed_reason": f"Risk={risk}, P={p_out:.2f}" if state == "OFF" else None,
            })

        total_revenue_plan += hour_revenue_plan
        total_revenue_naive += hour_revenue_naive

        hourly_plan.append({
            "hour_offset": hour_data["hour_offset"],
            "timestamp": hour_data["timestamp"],
            "hour": h,
            "p_outage": p_out,
            "risk_level": risk,
            "expected_duration_min": exp_dur,
            "appliances": appliance_states,
            "hour_revenue_plan_rwf": round(hour_revenue_plan, 0),
            "hour_revenue_naive_rwf": round(hour_revenue_naive, 0),
        })

    revenue_saved = total_revenue_plan - total_revenue_naive
    # In HIGH risk periods, turning off luxury/comfort means we don't waste startup costs
    # but main saving is avoiding the disruption penalty we model as a 20% recovery cost
    disruption_penalty = total_revenue_naive * 0.20
    net_benefit = revenue_saved + disruption_penalty

    return {
        "business": business["name"],
        "business_id": business_id,
        "plan": hourly_plan,
        "summary": {
            "total_revenue_plan_rwf": round(total_revenue_plan, 0),
            "total_revenue_naive_rwf": round(total_revenue_naive, 0),
            "revenue_saved_rwf": round(revenue_saved, 0),
            "disruption_penalty_avoided_rwf": round(disruption_penalty, 0),
            "net_benefit_rwf": round(net_benefit, 0),
            "hours_with_shed": sum(
                1 for h in hourly_plan
                if any(a["state"] == "OFF" for a in h["appliances"])
            ),
        },
    }


def format_digest(plan_result: dict, forecast: list) -> list[str]:
    """
    Generate 3 SMS messages (max 160 chars each) for the morning digest.
    Designed for feature phone delivery.
    """
    biz = plan_result["business"]
    summary = plan_result["summary"]
    hourly = plan_result["plan"]

    # Find highest-risk hours
    high_risk_hours = [h for h in hourly if h["risk_level"] == "HIGH"]
    med_risk_hours = [h for h in hourly if h["risk_level"] == "MEDIUM"]

    if high_risk_hours:
        risk_times = ",".join([str(h["hour"]) + "h" for h in high_risk_hours[:3]])
        risk_word = "HIGH"
    elif med_risk_hours:
        risk_times = ",".join([str(h["hour"]) + "h" for h in med_risk_hours[:3]])
        risk_word = "MED"
    else:
        risk_times = "none"
        risk_word = "LOW"

    # Appliances to shed
    shed_hours = [h for h in hourly if any(a["state"] == "OFF" for a in h["appliances"])]
    if shed_hours:
        sample_hour = shed_hours[0]
        shed_names = [a["name"].split()[0] for a in sample_hour["appliances"]
                      if a["state"] == "OFF"][:2]
        shed_str = "+".join(shed_names)
    else:
        shed_str = "none"

    net = int(summary["net_benefit_rwf"])
    saved_str = f"{net:,}RWF"

    sms1 = f"UMURIRO FORECAST 24H: Risk={risk_word} at {risk_times}. Shed: {shed_str}. Est.save: {saved_str}. Stay alert!"
    sms2 = f"PLAN: Turn OFF {shed_str} during risk hrs ({risk_times}). Keep dryer+clippers+lights ON. Generator ready?"
    sms3 = f"If no signal by 13h, use YESTERDAY plan. Risk valid 6h. Call 0788-GRID for live update. Good business!"

    # Enforce 160 char limit
    sms1 = sms1[:160]
    sms2 = sms2[:160]
    sms3 = sms3[:160]

    return [sms1, sms2, sms3]


def print_plan(plan_result: dict):
    """Pretty-print the 24h plan to terminal."""
    print(f"\n{'='*70}")
    print(f"  LOAD-SHEDDING PLAN — {plan_result['business']}")
    print(f"{'='*70}")
    print(f"{'Hour':>5} {'Time':>12} {'Risk':>6} {'P(out)':>7} | Appliances OFF")
    print("-" * 70)
    for h in plan_result["plan"]:
        off = [a["name"][:12] for a in h["appliances"] if a["state"] == "OFF"]
        off_str = ", ".join(off) if off else "—"
        print(f"{h['hour']:>5} {h['timestamp'][11:]:>12} {h['risk_level']:>6} "
              f"{h['p_outage']:>7.3f} | {off_str}")
    print("-" * 70)
    s = plan_result["summary"]
    print(f"  Net benefit vs naïve: {s['net_benefit_rwf']:,.0f} RWF")
    print(f"  Revenue (plan):       {s['total_revenue_plan_rwf']:,.0f} RWF")
    print(f"  Shed hours:           {s['hours_with_shed']}/24")
    print(f"{'='*70}\n")


if __name__ == "__main__":
    import sys
    from forecaster import Forecaster

    business_id = sys.argv[1] if len(sys.argv) > 1 else "salon"

    print(f"Fitting forecaster...")
    fc = Forecaster().fit("grid_history.csv")
    forecast = fc.predict_next_24h()

    appliances, businesses = load_data()

    print(f"\nGenerating plan for: {business_id}")
    result = plan(forecast, appliances, business_id=business_id)
    print_plan(result)

    # SMS digest
    sms_msgs = format_digest(result, forecast)
    print("📱 Morning SMS Digest (3×160 chars):")
    for i, msg in enumerate(sms_msgs, 1):
        print(f"  SMS {i} ({len(msg)} chars): {msg}")