Limit weather API calls to cron only, increase cache TTL to 24h

Web view was calling the OpenWeatherMap API directly on every request
(home + all trip locations), causing >1000 calls/day. Now web_view.py
uses cache_only=True everywhere so it never triggers live API calls —
update.py (cron) is the sole API caller. Also warms home location cache
in update_weather(), and increases TTL from 6h to 24h.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-03-14 09:27:56 +00:00
parent 04cb3e8179
commit feaefba03c
3 changed files with 45 additions and 6 deletions

View file

@ -14,7 +14,7 @@ def _cache_path(data_dir: str, lat: float, lon: float) -> str:
return os.path.join(weather_dir, f"{lat:.2f}_{lon:.2f}.json") return os.path.join(weather_dir, f"{lat:.2f}_{lon:.2f}.json")
def _is_fresh(path: str, max_age_hours: int = 6) -> bool: def _is_fresh(path: str, max_age_hours: int = 24) -> bool:
"""Return True if the cache file exists and is recent enough.""" """Return True if the cache file exists and is recent enough."""
if not os.path.exists(path): if not os.path.exists(path):
return False return False
@ -22,14 +22,30 @@ def _is_fresh(path: str, max_age_hours: int = 6) -> bool:
return age < max_age_hours * 3600 return age < max_age_hours * 3600
def get_forecast(data_dir: str, api_key: str, lat: float, lon: float) -> list[dict]: def get_forecast(
"""Return 8-day daily forecast for lat/lon, caching results for 6 hours.""" data_dir: str,
api_key: str,
lat: float,
lon: float,
cache_only: bool = False,
) -> list[dict]:
"""Return 8-day daily forecast for lat/lon, caching results for 24 hours.
If cache_only=True, return cached data if available (even if stale) and
never call the API. Returns [] if no cache exists.
"""
cache_file = _cache_path(data_dir, lat, lon) cache_file = _cache_path(data_dir, lat, lon)
if _is_fresh(cache_file): if _is_fresh(cache_file):
with open(cache_file) as f: with open(cache_file) as f:
return json.load(f) # type: ignore[no-any-return] return json.load(f) # type: ignore[no-any-return]
if cache_only:
if os.path.exists(cache_file):
with open(cache_file) as f:
return json.load(f) # type: ignore[no-any-return]
return []
owm = pyowm.OWM(api_key) owm = pyowm.OWM(api_key)
mgr = owm.weather_manager() mgr = owm.weather_manager()
result = mgr.one_call(lat=lat, lon=lon) result = mgr.one_call(lat=lat, lon=lon)
@ -67,17 +83,23 @@ def trip_latlon(trip: object) -> tuple[float, float] | None:
return None return None
def get_trip_weather(data_dir: str, api_key: str, trip: object) -> dict[str, dict]: def get_trip_weather(
data_dir: str,
api_key: str,
trip: object,
cache_only: bool = False,
) -> dict[str, dict]:
"""Return forecast for a trip keyed by date ISO string. """Return forecast for a trip keyed by date ISO string.
Returns an empty dict if no location is known or the API call fails. Returns an empty dict if no location is known or the API call fails.
If cache_only=True, never call the API (returns stale or empty data).
""" """
latlon = trip_latlon(trip) latlon = trip_latlon(trip)
if not latlon: if not latlon:
return {} return {}
lat, lon = latlon lat, lon = latlon
try: try:
forecasts = get_forecast(data_dir, api_key, lat, lon) forecasts = get_forecast(data_dir, api_key, lat, lon, cache_only=cache_only)
except Exception: except Exception:
return {} return {}
return {f["date"]: f for f in forecasts} return {f["date"]: f for f in forecasts}

View file

@ -410,7 +410,7 @@ def update_gandi(config: flask.config.Config) -> None:
def update_weather(config: flask.config.Config) -> None: def update_weather(config: flask.config.Config) -> None:
"""Refresh weather cache for all upcoming trips.""" """Refresh weather cache for home and all upcoming trips."""
from datetime import date, timedelta from datetime import date, timedelta
today = date.today() today = date.today()
@ -425,6 +425,20 @@ def update_weather(config: flask.config.Config) -> None:
seen: set[tuple[float, float]] = set() seen: set[tuple[float, float]] = set()
count = 0 count = 0
# Always include home location
home_lat = config.get("HOME_LATITUDE")
home_lon = config.get("HOME_LONGITUDE")
if home_lat is not None and home_lon is not None:
seen.add((home_lat, home_lon))
try:
agenda.weather.get_forecast(
config["DATA_DIR"], config["OPENWEATHERMAP_API_KEY"], home_lat, home_lon
)
count += 1
except Exception as exc:
print(f"weather update failed for home {home_lat},{home_lon}: {exc}")
for trip in upcoming: for trip in upcoming:
latlon = agenda.weather.trip_latlon(trip) latlon = agenda.weather.trip_latlon(trip)
if not latlon or latlon in seen: if not latlon or latlon in seen:

View file

@ -627,6 +627,7 @@ def get_home_weather() -> list[StrDict]:
app.config.get("OPENWEATHERMAP_API_KEY", ""), app.config.get("OPENWEATHERMAP_API_KEY", ""),
app.config["HOME_LATITUDE"], app.config["HOME_LATITUDE"],
app.config["HOME_LONGITUDE"], app.config["HOME_LONGITUDE"],
cache_only=True,
) )
for f in forecasts: for f in forecasts:
f["date_obj"] = date_type.fromisoformat(f["date"]) f["date_obj"] = date_type.fromisoformat(f["date"])
@ -729,6 +730,7 @@ def trip_future_list() -> str:
app.config["DATA_DIR"], app.config["DATA_DIR"],
app.config.get("OPENWEATHERMAP_API_KEY", ""), app.config.get("OPENWEATHERMAP_API_KEY", ""),
trip, trip,
cache_only=True,
) )
for trip in shown for trip in shown
} }
@ -1049,6 +1051,7 @@ def trip_page(start: str) -> str:
app.config["DATA_DIR"], app.config["DATA_DIR"],
app.config.get("OPENWEATHERMAP_API_KEY", ""), app.config.get("OPENWEATHERMAP_API_KEY", ""),
trip, trip,
cache_only=True,
) )
return flask.render_template( return flask.render_template(