X-Git-Url: https://wannabe.guru.org/gitweb/?a=blobdiff_plain;f=cached%2Fweather_data.py;h=91d665dbfd2e068ac2a10fc1ff867d552db3e71b;hb=36be25ae450f455cc6c798df44e5af038f068c41;hp=4c464483386b103c4a3618174e22a3e3f0b3e433;hpb=d2730e42f1160d45ab6c7780987b16ae83c616f6;p=python_utils.git diff --git a/cached/weather_data.py b/cached/weather_data.py index 4c46448..91d665d 100644 --- a/cached/weather_data.py +++ b/cached/weather_data.py @@ -1,16 +1,33 @@ #!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# © Copyright 2021-2022, Scott Gasch + +"""A cache of weather data for Bellevue, WA. +:class:`CachedWeatherData` class that derives from :class:`Persistent` +so that, on creation, the decorator transparently pulls in data from +disk, if possible, to avoid a network request. +""" -from dataclasses import dataclass import datetime import json -from typing import List +import logging +import os import urllib.request +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from overrides import overrides import argparse_utils import config import datetime_utils import list_utils import persistent +import scott_secrets +import type_utils + +logger = logging.getLogger(__name__) cfg = config.add_commandline_args( f'Cached Weather Data List ({__file__})', @@ -19,33 +36,53 @@ cfg = config.add_commandline_args( cfg.add_argument( '--weather_data_cachefile', type=str, - default='/home/scott/.weather_summary_cache', + default=f'{os.environ["HOME"]}/cache/.weather_summary_cache', metavar='FILENAME', - help='File in which to cache weather data' + 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 + default=datetime.timedelta(seconds=7200), # 2 hours metavar='DURATION', - help='Maximum acceptable age of cached data. If zero, forces a refetch' + 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 + date: datetime.date + """The date of the forecast""" + + high: float + """The predicted high temperature in F""" + + low: float + """The predicted low temperature in F""" + + precipitation_inches: float + """Number of inches of precipitation / day""" + + conditions: List[str] + """Conditions per ~3h window""" + most_common_condition: str + """The most common condition of the day""" -@persistent.persistent_autoloaded_singleton() + icon: str + """An icon representing the most common condition of the day""" + + +@persistent.persistent_autoloaded_singleton() # type: ignore class CachedWeatherData(persistent.Persistent): - def __init__(self, - weather_data = None): + def __init__(self, weather_data: Dict[datetime.date, WeatherData] = None): + """C'tor. Do not pass a dict except for testing purposes. + + The @persistent_autoloaded_singleton decorator handles + invoking our load and save methods at construction time for + you. + """ + if weather_data is not None: self.weather_data = weather_data return @@ -65,34 +102,48 @@ class CachedWeatherData(persistent.Persistent): "Sand": "🏜️", "Ash": "🌋", "Squall": "🌬", - "Tornado": "🌪️" + "Tornado": "🌪️", } now = datetime.datetime.now() dates = set() - highs = {} - lows = {} - conditions = {} - param = "id=5786882" # Bellevue, WA - key = "c0b160c49743622f62a9cd3cda0270b3" + highs: Dict[datetime.date, Optional[float]] = {} + lows: Dict[datetime.date, Optional[float]] = {} + conditions: Dict[datetime.date, List[str]] = {} + precip: Dict[datetime.date, float] = {} + param = "id=5786882" # Bellevue, WA + key = scott_secrets.OPEN_WEATHER_MAP_KEY 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) + logger.debug(parsed_json) dt = datetime.datetime.fromtimestamp(parsed_json["dt"]).date() dates.add(dt) condition = parsed_json["weather"][0]["main"] icon = icon_by_condition.get(condition, '?') + p = 0.0 + if 'rain' in parsed_json: + if '3h' in parsed_json['rain']: + p += float(parsed_json['rain']['3h']) + elif '1h' in parsed_json['rain']: + p += float(parsed_json['rain']['1h']) + if 'snow' in parsed_json: + if '3h' in parsed_json['snow']: + p += float(parsed_json['snow']['3h']) + elif '1h' in parsed_json['snow']: + p += float(parsed_json['snow']['1h']) 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, + date=dt, + high=float(parsed_json["main"]["temp_max"]), + low=float(parsed_json["main"]["temp_min"]), + precipitation_inches=p / 25.4, + conditions=[condition], + most_common_condition=condition, + icon=icon, ) www = urllib.request.urlopen( @@ -101,6 +152,7 @@ class CachedWeatherData(persistent.Persistent): response = www.read() www.close() parsed_json = json.loads(response) + logger.debug(parsed_json) count = parsed_json["cnt"] for x in range(count): data = parsed_json["list"][x] @@ -111,54 +163,86 @@ class CachedWeatherData(persistent.Persistent): 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 + for temp in ( + data["main"]["temp"], + data['main']['temp_min'], + data['main']['temp_max'], + ): + 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"] + precip[dt] = 0.0 + if 'rain' in parsed_json: + if '3h' in parsed_json['rain']: + precip[dt] += float(parsed_json['rain']['3h']) + elif '1h' in parsed_json['rain']: + precip[dt] += float(parsed_json['rain']['1h']) + if 'snow' in parsed_json: + if '3h' in parsed_json['snow']: + precip[dt] += float(parsed_json['snow']['3h']) + elif '1h' in parsed_json['snow']: + precip[dt] += float(parsed_json['snow']['1h']) 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 - ): + 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]) + most_common_condition = list_utils.most_common(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 + date=dt, + high=type_utils.unwrap_optional(highs[dt]), + low=type_utils.unwrap_optional(lows[dt]), + precipitation_inches=precip[dt] / 25.4, + conditions=conditions[dt], + most_common_condition=most_common_condition, + icon=icon, ) @classmethod - def load(cls): + @overrides + def load(cls) -> Any: + + """Depending on whether we have fresh data persisted either uses that + data to instantiate the class or makes an HTTP request to fetch the + necessary data. + + Note that because this is a subclass of Persistent this is taken + care of automatically. + """ + if persistent.was_file_written_within_n_seconds( - config.config['weather_data_cachefile'], - config.config['weather_data_stalest_acceptable'].total_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): + @overrides + def save(self) -> bool: + """ + Saves the current data to disk if required. Again, because this is + a subclass of Persistent this is taken care of for you. + """ + import pickle + with open(config.config['weather_data_cachefile'], 'wb') as wf: pickle.dump( self.weather_data, wf, pickle.HIGHEST_PROTOCOL, ) + return True