Add some classes that use persistent to cache data about the weather
authorScott Gasch <[email protected]>
Sat, 25 Sep 2021 14:18:30 +0000 (07:18 -0700)
committerScott Gasch <[email protected]>
Sat, 25 Sep 2021 14:18:30 +0000 (07:18 -0700)
in Bellevue, WA.

cached/weather_data.py [new file with mode: 0644]
cached/weather_forecast.py [new file with mode: 0644]
dateparse/dateparse_utils.py

diff --git a/cached/weather_data.py b/cached/weather_data.py
new file mode 100644 (file)
index 0000000..4c46448
--- /dev/null
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+
+from dataclasses import dataclass
+import datetime
+import json
+from typing import List
+import urllib.request
+
+import argparse_utils
+import config
+import datetime_utils
+import list_utils
+import persistent
+
+cfg = config.add_commandline_args(
+    f'Cached Weather Data List ({__file__})',
+    'Arguments controlling cached weather data',
+)
+cfg.add_argument(
+    '--weather_data_cachefile',
+    type=str,
+    default='/home/scott/.weather_summary_cache',
+    metavar='FILENAME',
+    help='File in which to cache weather data'
+)
+cfg.add_argument(
+    '--weather_data_stalest_acceptable',
+    type=argparse_utils.valid_duration,
+    default=datetime.timedelta(seconds=7200),   # 2 hours
+    metavar='DURATION',
+    help='Maximum acceptable age of cached data.  If zero, forces a refetch'
+)
+
+
+@dataclass
+class WeatherData:
+    date: datetime.date              # The date
+    high: float                      # The predicted high in F
+    low: float                       # The predicted low in F
+    conditions: List[str]            # Conditions per ~3h window
+    most_common_condition: str       # The most common condition
+    icon: str                        # An icon to represent it
+
+
[email protected]_autoloaded_singleton()
+class CachedWeatherData(persistent.Persistent):
+    def __init__(self,
+                 weather_data = None):
+        if weather_data is not None:
+            self.weather_data = weather_data
+            return
+        self.weather_data = {}
+        icon_by_condition = {
+            "Thunderstorm": "⚡",
+            "Drizzle": "",
+            "Rain": "☂️",
+            "Snow": "❄️",
+            "Clear": "☀️",
+            "Clouds": "⛅",
+            "Mist": "🌫",
+            "Smoke": "🚬",
+            "Haze": "🌥️",
+            "Dust": "💨",
+            "Fog": "🌁",
+            "Sand": "🏜️",
+            "Ash": "🌋",
+            "Squall": "🌬",
+            "Tornado": "🌪️"
+        }
+        now = datetime.datetime.now()
+        dates = set()
+        highs = {}
+        lows = {}
+        conditions = {}
+        param = "id=5786882"   # Bellevue, WA
+        key = "c0b160c49743622f62a9cd3cda0270b3"
+        www = urllib.request.urlopen(
+            f'http://api.openweathermap.org/data/2.5/weather?zip=98005,us&APPID={key}&units=imperial'
+        )
+        response = www.read()
+        www.close()
+        parsed_json = json.loads(response)
+        dt = datetime.datetime.fromtimestamp(parsed_json["dt"]).date()
+        dates.add(dt)
+        condition = parsed_json["weather"][0]["main"]
+        icon = icon_by_condition.get(condition, '?')
+        if dt == now.date() and now.hour > 18 and condition == 'Clear':
+            icon = '🌙'
+        self.weather_data[dt] = WeatherData(
+            date = dt,
+            high = float(parsed_json["main"]["temp_max"]),
+            low = float(parsed_json["main"]["temp_min"]),
+            conditions = [condition],
+            most_common_condition = condition,
+            icon = icon,
+        )
+
+        www = urllib.request.urlopen(
+            f"http://api.openweathermap.org/data/2.5/forecast?{param}&APPID={key}&units=imperial"
+        )
+        response = www.read()
+        www.close()
+        parsed_json = json.loads(response)
+        count = parsed_json["cnt"]
+        for x in range(count):
+            data = parsed_json["list"][x]
+            dt = datetime.datetime.strptime(data['dt_txt'], '%Y-%m-%d %H:%M:%S')
+            dt = dt.date()
+            dates.add(dt)
+            if dt not in highs:
+                highs[dt] = None
+                lows[dt] = None
+                conditions[dt] = []
+            temp = data["main"]["temp"]
+            if highs[dt] is None or temp > highs[dt]:
+                highs[dt] = temp
+            if lows[dt] is None or temp < lows[dt]:
+                lows[dt] = temp
+            cond = data["weather"][0]["main"]
+            conditions[dt].append(cond)
+
+        today = datetime_utils.now_pacific().date()
+        for dt in sorted(dates):
+            if dt == today:
+                high = highs.get(dt, None)
+                if (
+                        high is not None and
+                        self.weather_data[today].high < high
+                ):
+                    self.weather_data[today].high = high
+                continue
+            most_common_condition = list_utils.most_common_item(conditions[dt])
+            icon = icon_by_condition.get(most_common_condition, '?')
+            if dt == now.date() and now.hour > 18 and condition == 'Clear':
+                icon = '🌙'
+            self.weather_data[dt] = WeatherData(
+                date = dt,
+                high = highs[dt],
+                low = lows[dt],
+                conditions = conditions[dt],
+                most_common_condition = most_common_condition,
+                icon = icon
+            )
+
+    @classmethod
+    def load(cls):
+        if persistent.was_file_written_within_n_seconds(
+                config.config['weather_data_cachefile'],
+                config.config['weather_data_stalest_acceptable'].total_seconds(),
+        ):
+            import pickle
+            with open(config.config['weather_data_cachefile'], 'rb') as rf:
+                weather_data = pickle.load(rf)
+                return cls(weather_data)
+        return None
+
+    def save(self):
+        import pickle
+        with open(config.config['weather_data_cachefile'], 'wb') as wf:
+            pickle.dump(
+                self.weather_data,
+                wf,
+                pickle.HIGHEST_PROTOCOL,
+            )
diff --git a/cached/weather_forecast.py b/cached/weather_forecast.py
new file mode 100644 (file)
index 0000000..ce4725d
--- /dev/null
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+
+from dataclasses import dataclass
+import datetime
+import logging
+import urllib.request
+
+import astral  # type: ignore
+from astral.sun import sun  # type: ignore
+from bs4 import BeautifulSoup  # type: ignore
+import pytz
+
+import argparse_utils
+import config
+import datetime_utils
+import dateparse.dateparse_utils as dp
+import persistent
+import text_utils
+
+logger = logging.getLogger(__name__)
+
+cfg = config.add_commandline_args(
+    f'Cached Weather Forecast ({__file__})',
+    'Arguments controlling detailed weather rendering'
+)
+cfg.add_argument(
+    '--weather_forecast_cachefile',
+    type=str,
+    default='/home/scott/.weather_forecast_cache',
+    metavar='FILENAME',
+    help='File in which to cache weather data'
+)
+cfg.add_argument(
+    '--weather_forecast_stalest_acceptable',
+    type=argparse_utils.valid_duration,
+    default=datetime.timedelta(seconds=7200),   # 2 hours
+    metavar='DURATION',
+    help='Maximum acceptable age of cached data.  If zero, forces a refetch'
+)
+
+
+@dataclass
+class WeatherForecast:
+    date: datetime.date                # The date
+    sunrise: datetime.datetime         # Sunrise datetime
+    sunset: datetime.datetime          # Sunset datetime
+    description: str                   # Textual description of weather
+
+
[email protected]_autoloaded_singleton()
+class CachedDetailedWeatherForecast(object):
+    def __init__(self, forecasts = None):
+        if forecasts is not None:
+            self.forecasts = forecasts
+            return
+
+        now = datetime_utils.now_pacific()
+        self.forecasts = {}
+
+        # Ask the raspberry pi about the outside temperature.
+        www = urllib.request.urlopen(
+            "http://10.0.0.75/~pi/outside_temp"
+        )
+        current_temp = www.read().decode("utf-8")
+        current_temp = float(current_temp)
+        current_temp *= (9/5)
+        current_temp += 32.0
+        current_temp = round(current_temp)
+        www.close()
+
+        # Get a weather forecast for Bellevue.
+        www = urllib.request.urlopen(
+            "https://forecast.weather.gov/MapClick.php?lat=47.652775&lon=-122.170716"
+        )
+        forecast_response = www.read()
+        www.close()
+
+        soup = BeautifulSoup(forecast_response, "html.parser")
+        forecast = soup.find(id='detailed-forecast-body')
+        parser = dp.DateParser()
+
+        last_dt = now
+        dt = now
+        for (day, txt) in zip(
+                forecast.find_all('b'),
+                forecast.find_all(class_='col-sm-10 forecast-text')
+        ):
+            last_dt = dt
+            try:
+                dt = parser.parse(day.get_text())
+            except Exception:
+                dt = last_dt
+            assert dt is not None
+
+            # Compute sunrise/sunset times on dt.
+            city = astral.LocationInfo(
+                "Bellevue", "USA", "US/Pacific", 47.653, -122.171
+            )
+            s = sun(city.observer, date=dt, tzinfo=pytz.timezone("US/Pacific"))
+            sunrise = s['sunrise']
+            sunset = s['sunset']
+
+            if dt.date == now.date:
+                blurb = f'{day.get_text()}: The current outside tempterature is {current_temp}.  ' + txt.get_text()
+            else:
+                blurb = f'{day.get_text()}: {txt.get_text()}'
+            blurb = text_utils.wrap_string(blurb, 80)
+
+            if dt.date() in self.forecasts:
+                self.forecasts[dt.date()].description += '\n' + blurb
+            else:
+                self.forecasts[dt.date()] = WeatherForecast(
+                    date = dt,
+                    sunrise = sunrise,
+                    sunset = sunset,
+                    description = blurb,
+                )
+
+    @classmethod
+    def load(cls):
+        if persistent.was_file_written_within_n_seconds(
+                config.config['weather_forecast_cachefile'],
+                config.config['weather_forecast_stalest_acceptable'].total_seconds(),
+        ):
+            import pickle
+            with open(config.config['weather_forecast_cachefile'], 'rb') as rf:
+                weather_data = pickle.load(rf)
+                return cls(weather_data)
+        return None
+
+    def save(self):
+        import pickle
+        with open(config.config['weather_forecast_cachefile'], 'wb') as wf:
+            pickle.dump(
+                self.forecasts,
+                wf,
+                pickle.HIGHEST_PROTOCOL,
+            )
index f354ad09ccbefc1f3227ab7e011e7a634b37336b..026a5137a07a6027924080f27ec03d41d0913016 100755 (executable)
@@ -50,7 +50,6 @@ def debug_parse(enter_or_exit_f: Callable[[Any, Any], None]):
 class ParseException(Exception):
     """An exception thrown during parsing because of unrecognized input."""
     def __init__(self, message: str) -> None:
-        logger.error(message)
         self.message = message
 
 
@@ -59,7 +58,6 @@ class RaisingErrorListener(antlr4.DiagnosticErrorListener):
     def syntaxError(
             self, recognizer, offendingSymbol, line, column, msg, e
     ):
-        logger.error(msg)
         raise ParseException(msg)
 
     def reportAmbiguity(