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.temp_min }}–{{ day.temp_max }}°C
+
+ {% endfor %}
+
+ {% endif %}
+
{% if errors %}
{% for error in errors %}
diff --git a/templates/macros.html b/templates/macros.html
index dec35b4..35d64bd 100644
--- a/templates/macros.html
+++ b/templates/macros.html
@@ -474,8 +474,16 @@ https://www.flightradar24.com/data/flights/{{ flight.airline_detail.iata | lower
{{ conference_list(trip) }}
+ {% set trip_weather = trip_weather_map.get(trip.start.isoformat(), {}) if trip_weather_map is defined else {} %}
{% for day, elements in trip.elements_grouped_by_day() %}
+ {% set weather = trip_weather.get(day.isoformat()) %}
{{ display_date_no_year(day) }}
+ {% if weather %}
+
+
+ {{ weather.temp_min }}–{{ weather.temp_max }}°C {{ weather.detailed_status }}
+
+ {% endif %}
{% if g.user.is_authenticated and day <= today %}
photos
diff --git a/templates/trip_page.html b/templates/trip_page.html
index be164d2..c4c153c 100644
--- a/templates/trip_page.html
+++ b/templates/trip_page.html
@@ -377,6 +377,30 @@
{% 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 %}
+
+ | {{ display_date(day) }} |
+  |
+ {{ weather.temp_min }}–{{ weather.temp_max }}°C |
+ {{ weather.detailed_status }} |
+
+ {% endif %}
+ {% endfor %}
+
+ {% 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 %}{% endif %}
+ {% set w = weekend[day + '_weather'] %}
+ {% if w %}
+
+ {{ 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,
)