diff --git a/agenda/weather.py b/agenda/weather.py new file mode 100644 index 0000000..20a9817 --- /dev/null +++ b/agenda/weather.py @@ -0,0 +1,83 @@ +"""Weather forecast using OpenWeatherMap One Call API.""" + +import json +import os +from datetime import datetime + +import pyowm + + +def _cache_path(data_dir: str, lat: float, lon: float) -> str: + """Path for weather cache file.""" + weather_dir = os.path.join(data_dir, "weather") + os.makedirs(weather_dir, exist_ok=True) + return os.path.join(weather_dir, f"{lat:.2f}_{lon:.2f}.json") + + +def _is_fresh(path: str, max_age_hours: int = 6) -> bool: + """Return True if the cache file exists and is recent enough.""" + if not os.path.exists(path): + return False + age = datetime.now().timestamp() - os.path.getmtime(path) + return age < max_age_hours * 3600 + + +def get_forecast(data_dir: str, api_key: str, lat: float, lon: float) -> list[dict]: + """Return 8-day daily forecast for lat/lon, caching results for 6 hours.""" + cache_file = _cache_path(data_dir, lat, lon) + + if _is_fresh(cache_file): + with open(cache_file) as f: + return json.load(f) # type: ignore[no-any-return] + + owm = pyowm.OWM(api_key) + mgr = owm.weather_manager() + result = mgr.one_call(lat=lat, lon=lon) + + forecasts = [] + for day in result.forecast_daily: + dt = datetime.fromtimestamp(day.ref_time) + temp = day.temperature("celsius") + forecasts.append( + { + "date": dt.date().isoformat(), + "status": day.status, + "detailed_status": day.detailed_status, + "temp_min": round(temp["min"]), + "temp_max": round(temp["max"]), + "precipitation_probability": day.precipitation_probability, + "icon": day.weather_icon_name, + } + ) + + with open(cache_file, "w") as f: + json.dump(forecasts, f) + + return forecasts + + +def trip_latlon(trip: object) -> tuple[float, float] | None: + """Return (lat, lon) for the primary destination of a trip, or None.""" + from agenda.types import Trip + + assert isinstance(trip, Trip) + for item in list(trip.accommodation) + list(trip.conferences): + if "latitude" in item and "longitude" in item: + return (float(item["latitude"]), float(item["longitude"])) + return None + + +def get_trip_weather(data_dir: str, api_key: str, trip: object) -> dict[str, dict]: + """Return forecast for a trip keyed by date ISO string. + + Returns an empty dict if no location is known or the API call fails. + """ + latlon = trip_latlon(trip) + if not latlon: + return {} + lat, lon = latlon + try: + forecasts = get_forecast(data_dir, api_key, lat, lon) + except Exception: + return {} + return {f["date"]: f for f in forecasts} diff --git a/templates/event_list.html b/templates/event_list.html index 04191e2..6adb8da 100644 --- a/templates/event_list.html +++ b/templates/event_list.html @@ -70,6 +70,19 @@ Sunset: {{ sunset.strftime("%H:%M:%S") }} + {% if home_weather %} +
+ Bristol weather: + {% for day in home_weather %} + + {{ day.date_obj.strftime("%-d %b") }} + {{ day.status }} + {{ day.temp_min }}–{{ day.temp_max }}°C + + {% endfor %} +
+ {% endif %} + {% if errors %} {% for error in errors %} {% endfor %} + {% if trip_weather %} +
+

Weather forecast

+ {% set ns = namespace(has_rows=false) %} + + {% for day, elements in trip.elements_grouped_by_day() %} + {% set weather = trip_weather.get(day.isoformat()) %} + {% if weather %} + {% set ns.has_rows = true %} + + + + + + + {% endif %} + {% endfor %} +
{{ display_date(day) }}{{ weather.status }}{{ weather.temp_min }}–{{ weather.temp_max }}°C{{ weather.detailed_status }}
+ {% if not ns.has_rows %} +

Forecast not yet available (available up to 8 days ahead).

+ {% endif %} +
+ {% endif %} +

Holidays

{% if holidays %} diff --git a/templates/weekends.html b/templates/weekends.html index b96f09a..665d2c9 100644 --- a/templates/weekends.html +++ b/templates/weekends.html @@ -12,8 +12,10 @@ Date Saturday Saturday Location + Saturday Weather Sunday Sunday Location + Sunday Weather @@ -51,6 +53,13 @@ {{ city }}, {{ country.flag }} {{ country.name }} {% endif %} + {% if extra_class %}{% else %}{% endif %} + {% set w = weekend[day + '_weather'] %} + {% if w %} + {{ w.status }} + {{ w.temp_min }}–{{ w.temp_max }}°C + {% endif %} + {% endfor %} {% endfor %} diff --git a/update.py b/update.py index 5e5960a..a059e63 100755 --- a/update.py +++ b/update.py @@ -19,9 +19,11 @@ import agenda.geomob import agenda.gwr import agenda.mail import agenda.thespacedevs +import agenda.trip import agenda.types import agenda.uk_holiday import agenda.uk_school_holiday +import agenda.weather from agenda.event import Event from agenda.types import StrDict from web_view import app @@ -406,6 +408,40 @@ def update_gandi(config: flask.config.Config) -> None: out.write(r.text) +def update_weather(config: flask.config.Config) -> None: + """Refresh weather cache for all upcoming trips.""" + from datetime import date, timedelta + + today = date.today() + forecast_window = today + timedelta(days=8) + + trips = agenda.trip.build_trip_list() + upcoming = [ + t + for t in trips + if (t.end or t.start) >= today and t.start <= forecast_window + ] + + seen: set[tuple[float, float]] = set() + count = 0 + for trip in upcoming: + latlon = agenda.weather.trip_latlon(trip) + if not latlon or latlon in seen: + continue + seen.add(latlon) + lat, lon = latlon + try: + agenda.weather.get_forecast( + config["DATA_DIR"], config["OPENWEATHERMAP_API_KEY"], lat, lon + ) + count += 1 + except Exception as exc: + print(f"weather update failed for {lat},{lon}: {exc}") + + if sys.stdin.isatty(): + print(f"updated weather for {count} location(s)") + + def check_birthday_reminders(config: flask.config.Config) -> None: """Send at most one grouped birthday reminder email per day. @@ -497,6 +533,7 @@ def main() -> None: agenda.fx.get_rates(app.config) + update_weather(app.config) update_thespacedevs(app.config) # Check for birthday reminders daily at 9 AM diff --git a/web_view.py b/web_view.py index e29d690..107f30f 100755 --- a/web_view.py +++ b/web_view.py @@ -31,6 +31,7 @@ import agenda.thespacedevs import agenda.trip import agenda.trip_schengen import agenda.utils +import agenda.weather from agenda import ical, calendar, format_list_with_ampersand, travel, uk_tz from agenda.event import Event from agenda.types import StrDict, Trip @@ -120,6 +121,7 @@ async def index() -> str: start_event_list=date.today() - timedelta(days=1), end_event_list=date.today() + timedelta(days=365 * 2), render_time=(time.time() - t0), + home_weather=get_home_weather(), **data, ) @@ -296,6 +298,15 @@ async def weekends() -> str: weekends = agenda.busy.weekends( start, busy_events, trip_list, app.config["PERSONAL_DATA"] ) + + home_weather = get_home_weather() + home_weather_by_date = {f["date"]: f for f in home_weather} + for weekend in weekends: + saturday = weekend["date"] + sunday = saturday + timedelta(days=1) + weekend["saturday_weather"] = home_weather_by_date.get(saturday.isoformat()) + weekend["sunday_weather"] = home_weather_by_date.get(sunday.isoformat()) + return flask.render_template( "weekends.html", items=weekends, @@ -583,6 +594,21 @@ def sum_distances_by_transport_type(trips: list[Trip]) -> list[tuple[str, float] return list(distances_by_transport_type.items()) +def get_home_weather() -> list[StrDict]: + """Get Bristol home weather forecast, with date objects added for templates.""" + from datetime import date as date_type + + forecasts = agenda.weather.get_forecast( + app.config["DATA_DIR"], + app.config.get("OPENWEATHERMAP_API_KEY", ""), + app.config["HOME_LATITUDE"], + app.config["HOME_LONGITUDE"], + ) + for f in forecasts: + f["date_obj"] = date_type.fromisoformat(f["date"]) + return forecasts + + def get_trip_list() -> list[Trip]: """Get trip list with route distances.""" route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) @@ -674,11 +700,21 @@ def trip_future_list() -> str: shown = current + future + trip_weather_map = { + trip.start.isoformat(): agenda.weather.get_trip_weather( + app.config["DATA_DIR"], + app.config.get("OPENWEATHERMAP_API_KEY", ""), + trip, + ) + for trip in shown + } + return flask.render_template( "trip/list.html", heading="Future trips", trips=shown, trip_school_holiday_map=trip_school_holiday_map(shown), + trip_weather_map=trip_weather_map, coordinates=coordinates, routes=routes, today=today, @@ -747,6 +783,12 @@ def trip_page(start: str) -> str: app.config["PERSONAL_DATA"], route.pop("geojson_filename") ) + trip_weather = agenda.weather.get_trip_weather( + app.config["DATA_DIR"], + app.config.get("OPENWEATHERMAP_API_KEY", ""), + trip, + ) + return flask.render_template( "trip_page.html", trip=trip, @@ -760,6 +802,7 @@ def trip_page(start: str) -> str: holidays=agenda.holidays.get_trip_holidays(trip), school_holidays=agenda.holidays.get_trip_school_holidays(trip), human_readable_delta=agenda.utils.human_readable_delta, + trip_weather=trip_weather, )