Add OpenWeatherMap weather forecasts. Closes #48
Show 8-day Bristol home weather on the index and weekends pages. Show destination weather per day on the trip list and trip page. Cache forecasts in ~/lib/data/weather/ and refresh via update.py. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
61e17d9c96
commit
b4f0a5bf5d
7 changed files with 217 additions and 0 deletions
83
agenda/weather.py
Normal file
83
agenda/weather.py
Normal file
|
|
@ -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}
|
||||||
|
|
@ -70,6 +70,19 @@
|
||||||
Sunset: {{ sunset.strftime("%H:%M:%S") }}</li>
|
Sunset: {{ sunset.strftime("%H:%M:%S") }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{% if home_weather %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Bristol weather:</strong>
|
||||||
|
{% for day in home_weather %}
|
||||||
|
<span class="me-3 text-nowrap">
|
||||||
|
<small class="text-muted">{{ day.date_obj.strftime("%-d %b") }}</small>
|
||||||
|
<img src="https://openweathermap.org/img/wn/{{ day.icon }}.png" alt="{{ day.status }}" title="{{ day.detailed_status }}" width="25" height="25">
|
||||||
|
{{ day.temp_min }}–{{ day.temp_max }}°C
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if errors %}
|
{% if errors %}
|
||||||
{% for error in errors %}
|
{% for error in errors %}
|
||||||
<div class="alert alert-danger" role="alert">
|
<div class="alert alert-danger" role="alert">
|
||||||
|
|
|
||||||
|
|
@ -474,8 +474,16 @@ https://www.flightradar24.com/data/flights/{{ flight.airline_detail.iata | lower
|
||||||
|
|
||||||
{{ conference_list(trip) }}
|
{{ 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() %}
|
{% for day, elements in trip.elements_grouped_by_day() %}
|
||||||
|
{% set weather = trip_weather.get(day.isoformat()) %}
|
||||||
<h4>{{ display_date_no_year(day) }}
|
<h4>{{ display_date_no_year(day) }}
|
||||||
|
{% if weather %}
|
||||||
|
<small class="text-muted">
|
||||||
|
<img src="https://openweathermap.org/img/wn/{{ weather.icon }}.png" alt="{{ weather.status }}" title="{{ weather.detailed_status }}" width="25" height="25">
|
||||||
|
{{ weather.temp_min }}–{{ weather.temp_max }}°C {{ weather.detailed_status }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
{% if g.user.is_authenticated and day <= today %}
|
{% if g.user.is_authenticated and day <= today %}
|
||||||
<span class="lead">
|
<span class="lead">
|
||||||
<a href="https://photos.4angle.com/search?query=%7B%22takenAfter%22%3A%22{{day}}T00%3A00%3A00.000Z%22%2C%22takenBefore%22%3A%22{{day}}T23%3A59%3A59.999Z%22%7D">photos</a>
|
<a href="https://photos.4angle.com/search?query=%7B%22takenAfter%22%3A%22{{day}}T00%3A00%3A00.000Z%22%2C%22takenBefore%22%3A%22{{day}}T23%3A59%3A59.999Z%22%7D">photos</a>
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,30 @@
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if trip_weather %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<h4>Weather forecast</h4>
|
||||||
|
{% set ns = namespace(has_rows=false) %}
|
||||||
|
<table class="table table-hover w-auto">
|
||||||
|
{% for day, elements in trip.elements_grouped_by_day() %}
|
||||||
|
{% set weather = trip_weather.get(day.isoformat()) %}
|
||||||
|
{% if weather %}
|
||||||
|
{% set ns.has_rows = true %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-end text-nowrap">{{ display_date(day) }}</td>
|
||||||
|
<td><img src="https://openweathermap.org/img/wn/{{ weather.icon }}.png" alt="{{ weather.status }}" title="{{ weather.detailed_status }}" width="25" height="25"></td>
|
||||||
|
<td>{{ weather.temp_min }}–{{ weather.temp_max }}°C</td>
|
||||||
|
<td>{{ weather.detailed_status }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% if not ns.has_rows %}
|
||||||
|
<p class="text-muted">Forecast not yet available (available up to 8 days ahead).</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<h4>Holidays</h4>
|
<h4>Holidays</h4>
|
||||||
{% if holidays %}
|
{% if holidays %}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,10 @@
|
||||||
<th class="text-end">Date</th>
|
<th class="text-end">Date</th>
|
||||||
<th>Saturday</th>
|
<th>Saturday</th>
|
||||||
<th>Saturday Location</th>
|
<th>Saturday Location</th>
|
||||||
|
<th>Saturday Weather</th>
|
||||||
<th>Sunday</th>
|
<th>Sunday</th>
|
||||||
<th>Sunday Location</th>
|
<th>Sunday Location</th>
|
||||||
|
<th>Sunday Weather</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -51,6 +53,13 @@
|
||||||
{{ city }}, {{ country.flag }} {{ country.name }}
|
{{ city }}, {{ country.flag }} {{ country.name }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
{% if extra_class %}<td class="{{ extra_class|trim }}">{% else %}<td>{% endif %}
|
||||||
|
{% set w = weekend[day + '_weather'] %}
|
||||||
|
{% if w %}
|
||||||
|
<img src="https://openweathermap.org/img/wn/{{ w.icon }}.png" alt="{{ w.status }}" title="{{ w.detailed_status }}" width="25" height="25">
|
||||||
|
{{ w.temp_min }}–{{ w.temp_max }}°C
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
37
update.py
37
update.py
|
|
@ -19,9 +19,11 @@ import agenda.geomob
|
||||||
import agenda.gwr
|
import agenda.gwr
|
||||||
import agenda.mail
|
import agenda.mail
|
||||||
import agenda.thespacedevs
|
import agenda.thespacedevs
|
||||||
|
import agenda.trip
|
||||||
import agenda.types
|
import agenda.types
|
||||||
import agenda.uk_holiday
|
import agenda.uk_holiday
|
||||||
import agenda.uk_school_holiday
|
import agenda.uk_school_holiday
|
||||||
|
import agenda.weather
|
||||||
from agenda.event import Event
|
from agenda.event import Event
|
||||||
from agenda.types import StrDict
|
from agenda.types import StrDict
|
||||||
from web_view import app
|
from web_view import app
|
||||||
|
|
@ -406,6 +408,40 @@ def update_gandi(config: flask.config.Config) -> None:
|
||||||
out.write(r.text)
|
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:
|
def check_birthday_reminders(config: flask.config.Config) -> None:
|
||||||
"""Send at most one grouped birthday reminder email per day.
|
"""Send at most one grouped birthday reminder email per day.
|
||||||
|
|
||||||
|
|
@ -497,6 +533,7 @@ def main() -> None:
|
||||||
|
|
||||||
agenda.fx.get_rates(app.config)
|
agenda.fx.get_rates(app.config)
|
||||||
|
|
||||||
|
update_weather(app.config)
|
||||||
update_thespacedevs(app.config)
|
update_thespacedevs(app.config)
|
||||||
|
|
||||||
# Check for birthday reminders daily at 9 AM
|
# Check for birthday reminders daily at 9 AM
|
||||||
|
|
|
||||||
43
web_view.py
43
web_view.py
|
|
@ -31,6 +31,7 @@ import agenda.thespacedevs
|
||||||
import agenda.trip
|
import agenda.trip
|
||||||
import agenda.trip_schengen
|
import agenda.trip_schengen
|
||||||
import agenda.utils
|
import agenda.utils
|
||||||
|
import agenda.weather
|
||||||
from agenda import ical, calendar, format_list_with_ampersand, travel, uk_tz
|
from agenda import ical, calendar, format_list_with_ampersand, travel, uk_tz
|
||||||
from agenda.event import Event
|
from agenda.event import Event
|
||||||
from agenda.types import StrDict, Trip
|
from agenda.types import StrDict, Trip
|
||||||
|
|
@ -120,6 +121,7 @@ async def index() -> str:
|
||||||
start_event_list=date.today() - timedelta(days=1),
|
start_event_list=date.today() - timedelta(days=1),
|
||||||
end_event_list=date.today() + timedelta(days=365 * 2),
|
end_event_list=date.today() + timedelta(days=365 * 2),
|
||||||
render_time=(time.time() - t0),
|
render_time=(time.time() - t0),
|
||||||
|
home_weather=get_home_weather(),
|
||||||
**data,
|
**data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -296,6 +298,15 @@ async def weekends() -> str:
|
||||||
weekends = agenda.busy.weekends(
|
weekends = agenda.busy.weekends(
|
||||||
start, busy_events, trip_list, app.config["PERSONAL_DATA"]
|
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(
|
return flask.render_template(
|
||||||
"weekends.html",
|
"weekends.html",
|
||||||
items=weekends,
|
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())
|
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]:
|
def get_trip_list() -> list[Trip]:
|
||||||
"""Get trip list with route distances."""
|
"""Get trip list with route distances."""
|
||||||
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
|
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
|
||||||
|
|
@ -674,11 +700,21 @@ def trip_future_list() -> str:
|
||||||
|
|
||||||
shown = current + future
|
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(
|
return flask.render_template(
|
||||||
"trip/list.html",
|
"trip/list.html",
|
||||||
heading="Future trips",
|
heading="Future trips",
|
||||||
trips=shown,
|
trips=shown,
|
||||||
trip_school_holiday_map=trip_school_holiday_map(shown),
|
trip_school_holiday_map=trip_school_holiday_map(shown),
|
||||||
|
trip_weather_map=trip_weather_map,
|
||||||
coordinates=coordinates,
|
coordinates=coordinates,
|
||||||
routes=routes,
|
routes=routes,
|
||||||
today=today,
|
today=today,
|
||||||
|
|
@ -747,6 +783,12 @@ def trip_page(start: str) -> str:
|
||||||
app.config["PERSONAL_DATA"], route.pop("geojson_filename")
|
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(
|
return flask.render_template(
|
||||||
"trip_page.html",
|
"trip_page.html",
|
||||||
trip=trip,
|
trip=trip,
|
||||||
|
|
@ -760,6 +802,7 @@ def trip_page(start: str) -> str:
|
||||||
holidays=agenda.holidays.get_trip_holidays(trip),
|
holidays=agenda.holidays.get_trip_holidays(trip),
|
||||||
school_holidays=agenda.holidays.get_trip_school_holidays(trip),
|
school_holidays=agenda.holidays.get_trip_school_holidays(trip),
|
||||||
human_readable_delta=agenda.utils.human_readable_delta,
|
human_readable_delta=agenda.utils.human_readable_delta,
|
||||||
|
trip_weather=trip_weather,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue