Add some classes that use persistent to cache data about the weather
[python_utils.git] / cached / weather_forecast.py
1 #!/usr/bin/env python3
2
3 from dataclasses import dataclass
4 import datetime
5 import logging
6 import urllib.request
7
8 import astral  # type: ignore
9 from astral.sun import sun  # type: ignore
10 from bs4 import BeautifulSoup  # type: ignore
11 import pytz
12
13 import argparse_utils
14 import config
15 import datetime_utils
16 import dateparse.dateparse_utils as dp
17 import persistent
18 import text_utils
19
20 logger = logging.getLogger(__name__)
21
22 cfg = config.add_commandline_args(
23     f'Cached Weather Forecast ({__file__})',
24     'Arguments controlling detailed weather rendering'
25 )
26 cfg.add_argument(
27     '--weather_forecast_cachefile',
28     type=str,
29     default='/home/scott/.weather_forecast_cache',
30     metavar='FILENAME',
31     help='File in which to cache weather data'
32 )
33 cfg.add_argument(
34     '--weather_forecast_stalest_acceptable',
35     type=argparse_utils.valid_duration,
36     default=datetime.timedelta(seconds=7200),   # 2 hours
37     metavar='DURATION',
38     help='Maximum acceptable age of cached data.  If zero, forces a refetch'
39 )
40
41
42 @dataclass
43 class WeatherForecast:
44     date: datetime.date                # The date
45     sunrise: datetime.datetime         # Sunrise datetime
46     sunset: datetime.datetime          # Sunset datetime
47     description: str                   # Textual description of weather
48
49
50 @persistent.persistent_autoloaded_singleton()
51 class CachedDetailedWeatherForecast(object):
52     def __init__(self, forecasts = None):
53         if forecasts is not None:
54             self.forecasts = forecasts
55             return
56
57         now = datetime_utils.now_pacific()
58         self.forecasts = {}
59
60         # Ask the raspberry pi about the outside temperature.
61         www = urllib.request.urlopen(
62             "http://10.0.0.75/~pi/outside_temp"
63         )
64         current_temp = www.read().decode("utf-8")
65         current_temp = float(current_temp)
66         current_temp *= (9/5)
67         current_temp += 32.0
68         current_temp = round(current_temp)
69         www.close()
70
71         # Get a weather forecast for Bellevue.
72         www = urllib.request.urlopen(
73             "https://forecast.weather.gov/MapClick.php?lat=47.652775&lon=-122.170716"
74         )
75         forecast_response = www.read()
76         www.close()
77
78         soup = BeautifulSoup(forecast_response, "html.parser")
79         forecast = soup.find(id='detailed-forecast-body')
80         parser = dp.DateParser()
81
82         last_dt = now
83         dt = now
84         for (day, txt) in zip(
85                 forecast.find_all('b'),
86                 forecast.find_all(class_='col-sm-10 forecast-text')
87         ):
88             last_dt = dt
89             try:
90                 dt = parser.parse(day.get_text())
91             except Exception:
92                 dt = last_dt
93             assert dt is not None
94
95             # Compute sunrise/sunset times on dt.
96             city = astral.LocationInfo(
97                 "Bellevue", "USA", "US/Pacific", 47.653, -122.171
98             )
99             s = sun(city.observer, date=dt, tzinfo=pytz.timezone("US/Pacific"))
100             sunrise = s['sunrise']
101             sunset = s['sunset']
102
103             if dt.date == now.date:
104                 blurb = f'{day.get_text()}: The current outside tempterature is {current_temp}.  ' + txt.get_text()
105             else:
106                 blurb = f'{day.get_text()}: {txt.get_text()}'
107             blurb = text_utils.wrap_string(blurb, 80)
108
109             if dt.date() in self.forecasts:
110                 self.forecasts[dt.date()].description += '\n' + blurb
111             else:
112                 self.forecasts[dt.date()] = WeatherForecast(
113                     date = dt,
114                     sunrise = sunrise,
115                     sunset = sunset,
116                     description = blurb,
117                 )
118
119     @classmethod
120     def load(cls):
121         if persistent.was_file_written_within_n_seconds(
122                 config.config['weather_forecast_cachefile'],
123                 config.config['weather_forecast_stalest_acceptable'].total_seconds(),
124         ):
125             import pickle
126             with open(config.config['weather_forecast_cachefile'], 'rb') as rf:
127                 weather_data = pickle.load(rf)
128                 return cls(weather_data)
129         return None
130
131     def save(self):
132         import pickle
133         with open(config.config['weather_forecast_cachefile'], 'wb') as wf:
134             pickle.dump(
135                 self.forecasts,
136                 wf,
137                 pickle.HIGHEST_PROTOCOL,
138             )