Somewhat large overhaul to move the kiosk towards using normal python
[kiosk.git] / weather_renderer.py
1 #!/usr/bin/env python3
2
3 from datetime import datetime
4 import json
5 from typing import Dict, List
6 import urllib.request
7 import urllib.error
8 import urllib.parse
9
10 import file_writer
11 import renderer
12 import kiosk_secrets as secrets
13 import random
14
15
16 class weather_renderer(renderer.abstaining_renderer):
17     """A renderer to fetch forecast from wunderground."""
18
19     def __init__(self, name_to_timeout_dict: Dict[str, int], file_prefix: str) -> None:
20         super().__init__(name_to_timeout_dict)
21         self.file_prefix = file_prefix
22
23     def debug_prefix(self) -> str:
24         return f"weather({self.file_prefix})"
25
26     def periodic_render(self, key: str) -> bool:
27         return self.fetch_weather()
28
29     def describe_time(self, index: int) -> str:
30         if index <= 1:
31             return "overnight"
32         elif index <= 3:
33             return "morning"
34         elif index <= 5:
35             return "afternoon"
36         else:
37             return "evening"
38
39     def describe_wind(self, mph: float) -> str:
40         if mph <= 0.3:
41             return "calm"
42         elif mph <= 5.0:
43             return "light"
44         elif mph < 15.0:
45             return "breezy"
46         elif mph <= 25.0:
47             return "gusty"
48         else:
49             return "heavy"
50
51     def describe_magnitude(self, mm: float) -> str:
52         if mm < 2.0:
53             return "light"
54         elif mm < 10.0:
55             return "moderate"
56         else:
57             return "heavy"
58
59     def describe_precip(self, rain: float, snow: float) -> str:
60         if rain == 0.0 and snow == 0.0:
61             return "no precipitation"
62         magnitude = rain + snow
63         if rain > 0 and snow > 0:
64             return f"a {self.describe_magnitude(magnitude)} mix of rain and snow"
65         elif rain > 0:
66             return f"{self.describe_magnitude(magnitude)} rain"
67         elif snow > 0:
68             return f"{self.describe_magnitude(magnitude)} snow"
69         return "rain"
70
71     def fix_caps(self, s: str) -> str:
72         r = ""
73         s = s.lower()
74         for x in s.split("."):
75             x = x.strip()
76             r += x.capitalize() + ". "
77         r = r.replace(". .", ".")
78         return r
79
80     def pick_icon(
81         self, conditions: List[str], rain: List[float], snow: List[float]
82     ) -> str:
83         #                     rain     snow    clouds    sun
84         # fog.gif
85         # hazy.gif
86         # clear.gif
87         # mostlycloudy.gif     F         F        6+      X
88         # partlycloudy.gif     F         F        4+      4-
89         # cloudy.gif
90         # partlysunny.gif      F         F        X       5+
91         # mostlysunny.gif      F         F        X       6+
92         # rain.gif             T         F        X       X
93         # sleet.gif            T         T        X       X
94         # flurries.gif         F         T        X       X    (<1")
95         # snow.gif             F         T        X       X    (else)
96         # sunny.gif            F         F        X       7+
97         # tstorms.gif
98         seen_rain = False
99         seen_snow = False
100         cloud_count = 0
101         clear_count = 0
102         total_snow = 0.0
103         count = min(len(conditions), len(rain), len(snow))
104         for x in range(0, count):
105             seen_rain = rain[x] > 0
106             seen_snow = snow[x] > 0
107             total_snow += snow[x]
108             txt = conditions[x].lower()
109             if "cloud" in txt:
110                 cloud_count += 1
111             if "clear" in txt or "sun" in txt:
112                 clear_count += 1
113
114         if seen_rain and seen_snow:
115             if total_snow < 10:
116                 return "sleet.gif"
117             else:
118                 return "snow.gif"
119         if seen_snow:
120             if total_snow < 10:
121                 return "flurries.gif"
122             else:
123                 return "snow.gif"
124         if seen_rain:
125             return "rain.gif"
126         if cloud_count >= 6:
127             return "mostlycloudy.gif"
128         elif cloud_count >= 4:
129             return "partlycloudy.gif"
130         if clear_count >= 7:
131             return "sunny.gif"
132         elif clear_count >= 6:
133             return "mostlysunny.gif"
134         elif clear_count >= 4:
135             return "partlysunny.gif"
136         return "clear.gif"
137
138     def describe_weather(
139         self,
140         high: float,
141         low: float,
142         wind: List[float],
143         conditions: List[str],
144         rain: List[float],
145         snow: List[float],
146     ) -> str:
147         # High temp: 65
148         # Low temp: 44
149         #             -onight------  -morning----- -afternoon--  -evening----
150         #             12a-3a  3a-6a  6a-9a  9a-12p 12p-3p 3p-6p  6p-9p 9p-12p
151         # Wind:       [12.1   3.06   3.47   4.12   3.69   3.31   2.73  2.1]
152         # Conditions: [Clouds Clouds Clouds Clouds Clouds Clouds Clear Clear]
153         # Rain:       [0.4    0.2    0      0      0      0      0     0]
154         # Snow:       [0      0      0      0      0      0      0     0]
155         high = int(high)
156         low = int(low)
157         count = min(len(wind), len(conditions), len(rain), len(snow))
158         descr = ""
159
160         lcondition = ""
161         lwind = ""
162         lprecip = ""
163         ltime = ""
164         for x in range(0, count):
165             time = self.describe_time(x)
166             current = ""
167             chunks = 0
168
169             txt = conditions[x]
170             if txt == "Clouds":
171                 txt = "cloudy"
172             elif txt == "Rain":
173                 txt = "rainy"
174
175             if txt != lcondition:
176                 if txt != "Snow" and txt != "Rain":
177                     current += txt
178                     chunks += 1
179                 lcondition = txt
180
181             txt = self.describe_wind(wind[x])
182             if txt != lwind:
183                 if len(current) > 0:
184                     current += " with "
185                 current += txt + " winds"
186                 lwind = txt
187                 chunks += 1
188
189             txt = self.describe_precip(rain[x], snow[x])
190             if txt != lprecip:
191                 if len(current) > 0:
192                     if chunks > 1:
193                         current += " and "
194                     else:
195                         current += " with "
196                 chunks += 1
197                 current += txt
198                 lprecip = txt
199
200             if len(current):
201                 if ltime != time:
202                     if random.randint(0, 3) == 0:
203                         if time != "overnight":
204                             descr += current + " in the " + time + ". "
205                         descr += current + " overnight. "
206                     else:
207                         if time != "overnight":
208                             descr += "In the "
209                         descr += time + ", " + current + ". "
210                 else:
211                     current = current.replace("cloudy", "clouds")
212                     descr += current + " developing. "
213                 ltime = time
214         if ltime == "overnight" or ltime == "morning":
215             descr += "Conditions continuing the rest of the day. "
216         descr = descr.replace("with breezy winds", "and breezy")
217         descr = descr.replace("Clear developing", "Skies clearing")
218         descr = self.fix_caps(descr)
219         return descr
220
221     def fetch_weather(self) -> bool:
222         if self.file_prefix == "stevens":
223             text_location = "Stevens Pass, WA"
224             param = "lat=47.74&lon=-121.08"
225         elif self.file_prefix == "telma":
226             text_location = "Telma, WA"
227             param = "lat=47.84&lon=-120.81"
228         else:
229             text_location = "Bellevue, WA"
230             param = "id=5786882"
231
232         www = urllib.request.urlopen(
233             "http://api.openweathermap.org/data/2.5/forecast?%s&APPID=%s&units=imperial"
234             % (param, secrets.openweather_key)
235         )
236         response = www.read()
237         www.close()
238         parsed_json = json.loads(response)
239
240         # https://openweathermap.org/forecast5
241         # {"cod":"200",
242         #  "message":0.0036,
243         #  "cnt":40,
244         #  "list":[
245         #     {"dt":1485799200,
246         #      "main":{"temp":261.45,"temp_min":259.086,"temp_max":261.45,"pressure":1023.48,"sea_level":1045.39,"grnd_level":1023.48,"humidity":79,"temp_kf":2.37},
247         #      "weather":[
248         #         {"id":800,"main":"Clear","description":"clear sky","icon":"02n"}
249         #      ],
250         #     "clouds":{"all":8},
251         #     "wind":{"speed":4.77,"deg":232.505},
252         #     "snow":{},
253         #     "sys":{"pod":"n"},
254         #     "dt_txt":"2017-01-30 18:00:00"
255         #     },
256         #     {"dt":1485810000,....
257         with file_writer.file_writer("weather-%s_3_10800.html" % self.file_prefix) as f:
258             f.write(
259                 f"""
260 <h1>Weather at {text_location}:</h1>
261 <hr>
262 <center>
263 <table width=99% cellspacing=10 border=0>
264     <tr>"""
265             )
266             count = parsed_json["cnt"]
267
268             ts = {}
269             highs = {}
270             lows = {}
271             wind: Dict[str, List[float]] = {}
272             conditions: Dict[str, List[str]] = {}
273             rain: Dict[str, List[float]] = {}
274             snow: Dict[str, List[float]] = {}
275             for x in range(0, count):
276                 data = parsed_json["list"][x]
277                 dt = data["dt_txt"]  # 2019-10-07 18:00:00
278                 date = dt.split(" ")[0]
279                 time = dt.split(" ")[1]
280                 wind[date] = []
281                 conditions[date] = []
282                 highs[date] = -99999
283                 lows[date] = +99999
284                 rain[date] = []
285                 snow[date] = []
286                 ts[date] = 0
287
288             for x in range(0, count):
289                 data = parsed_json["list"][x]
290                 dt = data["dt_txt"]  # 2019-10-07 18:00:00
291                 date = dt.split(" ")[0]
292                 time = dt.split(" ")[1]
293                 _ = data["dt"]
294                 if _ > ts[date]:
295                     ts[date] = _
296                 temp = data["main"]["temp"]
297                 if highs[date] < temp:
298                     highs[date] = temp
299                 if temp < lows[date]:
300                     lows[date] = temp
301                 wind[date].append(data["wind"]["speed"])
302                 conditions[date].append(data["weather"][0]["main"])
303                 if "rain" in data and "3h" in data["rain"]:
304                     rain[date].append(data["rain"]["3h"])
305                 else:
306                     rain[date].append(0)
307                 if "snow" in data and "3h" in data["snow"]:
308                     snow[date].append(data["snow"]["3h"])
309                 else:
310                     snow[date].append(0)
311
312                 # {u'clouds': {u'all': 0},
313                 #  u'sys': {u'pod': u'd'},
314                 #  u'dt_txt': u'2019-10-09 21:00:00',
315                 #  u'weather': [
316                 #      {u'main': u'Clear',
317                 #       u'id': 800,
318                 #       u'icon': u'01d',
319                 #       u'description': u'clear sky'}
320                 #  ],
321                 #  u'dt': 1570654800,
322                 #  u'main': {
323                 #       u'temp_kf': 0,
324                 #       u'temp': 54.74,
325                 #       u'grnd_level': 1018.95,
326                 #       u'temp_max': 54.74,
327                 #       u'sea_level': 1026.46,
328                 #       u'humidity': 37,
329                 #       u'pressure': 1026.46,
330                 #       u'temp_min': 54.74
331                 #  },
332                 #  u'wind': {u'speed': 6.31, u'deg': 10.09}}
333
334             # Next 5 half-days
335             # for x in xrange(0, 5):
336             #    fcast = parsed_json['forecast']['txt_forecast']['forecastday'][x]
337             #    text = fcast['fcttext']
338             #    text = re.subn(r' ([0-9]+)F', r' \1&deg;F', text)[0]
339             #    f.write('<td style="vertical-align:top;font-size:75%%"><P STYLE="padding:8px;">%s</P></td>' % text)
340             # f.write('</tr></table>')
341             # f.close()
342             # return True
343
344             # f.write("<table border=0 cellspacing=10>\n")
345             days_seen = {}
346             for date in sorted(highs.keys()):
347                 today = datetime.fromtimestamp(ts[date])
348                 formatted_date = today.strftime("%a %e %b")
349                 if formatted_date in days_seen:
350                     continue
351                 days_seen[formatted_date] = True
352             num_days = len(list(days_seen.keys()))
353
354             days_seen = {}
355             for date in sorted(highs.keys()):
356                 precip = 0.0
357                 for _ in rain[date]:
358                     precip += _
359                 for _ in snow[date]:
360                     precip += _
361
362                 today = datetime.fromtimestamp(ts[date])
363                 formatted_date = today.strftime("%a %e %b")
364                 if formatted_date in days_seen:
365                     continue
366                 days_seen[formatted_date] = True
367                 f.write(
368                     '<td width=%d%% style="vertical-align:top;">\n' % (100 / num_days)
369                 )
370                 f.write("<table border=0>\n")
371
372                 # Date
373                 f.write(
374                     "  <tr><td colspan=3 height=50><b><center><font size=6>"
375                     + formatted_date
376                     + "</font></center></b></td></tr>\n"
377                 )
378
379                 # Icon
380                 f.write(
381                     '  <tr><td colspan=3 height=100><center><img src="/kiosk/images/weather/%s" height=125></center></td></tr>\n'
382                     % self.pick_icon(conditions[date], rain[date], snow[date])
383                 )
384
385                 # Low temp
386                 color = "#000099"
387                 if lows[date] <= 32.5:
388                     color = "#009999"
389                 f.write(
390                     '  <tr><td width=33%% align=left><font color="%s"><b>%d&deg;F&nbsp;&nbsp;</b></font></td>\n'
391                     % (color, int(lows[date]))
392                 )
393
394                 # Total precip
395                 precip *= 0.0393701
396                 if precip > 0.025:
397                     f.write(
398                         '      <td width=33%%><center><b><font style="background-color:#dfdfff; color:#003355">%3.1f"</font></b></center></td>\n'
399                         % precip
400                     )
401                 else:
402                     f.write("      <td width=33%>&nbsp;</td>\n")
403
404                 # High temp
405                 color = "#800000"
406                 if highs[date] >= 80:
407                     color = "#AA0000"
408                 f.write(
409                     '      <td align=right><font color="%s"><b>&nbsp;&nbsp;%d&deg;F</b></font></td></tr>\n'
410                     % (color, int(highs[date]))
411                 )
412
413                 # Text "description"
414                 f.write(
415                     '<tr><td colspan=3 style="vertical-align:top;font-size:75%%">%s</td></tr>\n'
416                     % self.describe_weather(
417                         highs[date],
418                         lows[date],
419                         wind[date],
420                         conditions[date],
421                         rain[date],
422                         snow[date],
423                     )
424                 )
425                 f.write("</table>\n</td>\n")
426             f.write("</tr></table></center>")
427         return True
428
429
430 # x = weather_renderer({"Stevens": 1000}, "stevens")
431 # x.periodic_render("Stevens")