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}")
|