internal.analytics.forecast_info
Builds ForecastQuery objects by pulling in weather and holiday data.
Uses Open-Meteo for weather and the holidays library for UK holidays. Call build_forecast_query() to get a query ready to pass straight into generate_forecast() in forecast.py.
1"""Builds ForecastQuery objects by pulling in weather and holiday data. 2 3Uses Open-Meteo for weather and the holidays library for UK holidays. 4Call build_forecast_query() to get a query ready to pass straight into 5generate_forecast() in forecast.py. 6""" 7 8import datetime 9from decimal import Decimal 10 11import holidays 12import requests 13from internal.queries.models import DayOfWeek, WeatherFlag 14from pydantic import BaseModel 15 16from .forecast import ForecastQuery 17 18# --- Constants --- 19 20_FORECAST_URL: str = "https://api.open-meteo.com/v1/forecast" 21_PAST_URL: str = "https://archive-api.open-meteo.com/v1/archive" 22 23_TIMEOUT: int = 10 # seconds 24 25# WMO weather code boundaries used in _wmo_to_flag(). 26_WMO_SNOWY_MIN: int = 71 27_WMO_SNOWY_MAX: int = 86 28_WMO_RAINY_MIN: int = 51 29_WMO_RAINY_MAX: int = 82 30_WMO_WINDY_MIN: int = 95 31 32# date.weekday() returns 0-6 starting Monday — same order DayOfWeek is defined. 33_WEEKDAY_TO_ENUM: dict[int, DayOfWeek] = dict(enumerate(DayOfWeek)) 34 35# --- Bundle details dataclass --- 36 37 38class BundleDetails(BaseModel): 39 """Groups the basic details of an upcoming bundle for build_forecast_query(). 40 41 Keeps the argument count under the linter limit and makes call sites 42 easier to read. 43 """ 44 45 bundle_id: int 46 bundle_date: datetime.date 47 window_start: datetime.datetime 48 window_end: datetime.datetime 49 seller_id: int 50 category_ids: list[int] 51 latitude: float | None 52 longitude: float | None 53 posted_qty: int 54 55 56# --- Private helpers --- 57 58 59def _wmo_to_flag(code: int) -> WeatherFlag: 60 """Convert a WMO weather code from Open-Meteo into a WeatherFlag. 61 62 WMO ranges: 63 0 -> sunny 64 1-48 -> cloudy 65 51-82 -> rainy 66 71-86 -> snowy 67 95+ -> windy 68 69 Returns: 70 A WeatherFlag corresponding to the given WMO code. 71 """ 72 if code == 0: 73 return WeatherFlag.SUNNY 74 # Snowy must come before rainy. Codes 71-82 sit in both ranges. 75 if _WMO_SNOWY_MIN <= code <= _WMO_SNOWY_MAX: 76 return WeatherFlag.SNOWY 77 if _WMO_RAINY_MIN <= code <= _WMO_RAINY_MAX: 78 return WeatherFlag.RAINY 79 if code >= _WMO_WINDY_MIN: 80 return WeatherFlag.WINDY 81 return WeatherFlag.CLOUDY 82 83 84def _fetch_weather( 85 url: str, 86 bundle_date: datetime.date, 87 latitude: float | None, 88 longitude: float | None, 89) -> tuple[Decimal, WeatherFlag]: 90 """Pull max temperature and weather for the given date and location. 91 92 Returns: 93 A (temperature, WeatherFlag) tuple for the requested date. 94 95 Raises: 96 ValueError: Open-Meteo returned no data for the date. 97 """ 98 if latitude is None or longitude is None: 99 raise ValueError("Seller has no location data for weather lookup.") 100 101 date_str = bundle_date.isoformat() 102 103 response = requests.get( 104 url, 105 params={ 106 "latitude": str(latitude), 107 "longitude": str(longitude), 108 "daily": "temperature_2m_max,weathercode", 109 # Europe/London keeps daily windows aligned with UK midnight. 110 "timezone": "Europe/London", 111 "start_date": date_str, 112 "end_date": date_str, 113 }, 114 timeout=_TIMEOUT, 115 ) 116 response.raise_for_status() 117 118 daily = response.json()["daily"] 119 # API always returns lists even for a single date. 120 temperatures: list[float] = daily["temperature_2m_max"] 121 weather_codes: list[int] = daily["weathercode"] 122 123 if not temperatures or temperatures[0] is None: 124 raise ValueError( 125 f"Open-Meteo returned no data for {date_str} at ({latitude}, {longitude})." 126 ) 127 128 temperature = Decimal(str(round(temperatures[0], 2))) 129 weather_flag = _wmo_to_flag(int(weather_codes[0])) 130 131 return temperature, weather_flag 132 133 134def _is_uk_holiday(bundle_date: datetime.date) -> bool: 135 """Return True if the date is a bank holiday anywhere in the UK.""" 136 uk_holidays = holidays.country_holidays("GB") 137 return bundle_date in uk_holidays 138 139 140# --- Public functions --- 141 142 143def build_forecast_query(bundle: BundleDetails) -> ForecastQuery: 144 """Build a ForecastQuery for an upcoming bundle. 145 146 Handles all the external lookups — weather from Open-Meteo and UK bank 147 holidays — so the caller just passes in a BundleDetails object. 148 149 Args: 150 bundle: The basic details of the upcoming bundle. 151 152 Returns: 153 A fully populated ForecastQuery ready for generate_forecast(). 154 """ 155 day_of_week = _WEEKDAY_TO_ENUM[bundle.bundle_date.weekday()] 156 is_holiday = _is_uk_holiday(bundle.bundle_date) 157 temperature, weather_flag = _fetch_weather( 158 _FORECAST_URL, bundle.bundle_date, bundle.latitude, bundle.longitude 159 ) 160 161 return ForecastQuery( 162 bundle_id=bundle.bundle_id, 163 seller_id=bundle.seller_id, 164 category_ids=bundle.category_ids, 165 day_of_week=day_of_week, 166 window_start=bundle.window_start, 167 window_end=bundle.window_end, 168 is_holiday=is_holiday, 169 temperature=temperature, 170 weather_flag=weather_flag, 171 posted_qty=bundle.posted_qty, 172 ) 173 174 175def fetch_historical_weather( 176 bundle_date: datetime.date, latitude: float, longitude: float 177) -> tuple[Decimal, WeatherFlag]: 178 """Fetch the actual weather conditions for a completed bundle's date. 179 180 Called after a bundle's pickup window closes, before writing the 181 forecast_input row. Uses the Open-Meteo archive endpoint which holds 182 verified historical records rather than forecast data. 183 184 Args: 185 bundle_date: The date the bundle's pickup window fell on. 186 latitude: Seller latitude. 187 longitude: Seller longitude. 188 189 Returns: 190 A (temperature, WeatherFlag) tuple of the actual conditions on that day. 191 """ 192 return _fetch_weather(_PAST_URL, bundle_date, latitude, longitude)
39class BundleDetails(BaseModel): 40 """Groups the basic details of an upcoming bundle for build_forecast_query(). 41 42 Keeps the argument count under the linter limit and makes call sites 43 easier to read. 44 """ 45 46 bundle_id: int 47 bundle_date: datetime.date 48 window_start: datetime.datetime 49 window_end: datetime.datetime 50 seller_id: int 51 category_ids: list[int] 52 latitude: float | None 53 longitude: float | None 54 posted_qty: int
Groups the basic details of an upcoming bundle for build_forecast_query().
Keeps the argument count under the linter limit and makes call sites easier to read.
144def build_forecast_query(bundle: BundleDetails) -> ForecastQuery: 145 """Build a ForecastQuery for an upcoming bundle. 146 147 Handles all the external lookups — weather from Open-Meteo and UK bank 148 holidays — so the caller just passes in a BundleDetails object. 149 150 Args: 151 bundle: The basic details of the upcoming bundle. 152 153 Returns: 154 A fully populated ForecastQuery ready for generate_forecast(). 155 """ 156 day_of_week = _WEEKDAY_TO_ENUM[bundle.bundle_date.weekday()] 157 is_holiday = _is_uk_holiday(bundle.bundle_date) 158 temperature, weather_flag = _fetch_weather( 159 _FORECAST_URL, bundle.bundle_date, bundle.latitude, bundle.longitude 160 ) 161 162 return ForecastQuery( 163 bundle_id=bundle.bundle_id, 164 seller_id=bundle.seller_id, 165 category_ids=bundle.category_ids, 166 day_of_week=day_of_week, 167 window_start=bundle.window_start, 168 window_end=bundle.window_end, 169 is_holiday=is_holiday, 170 temperature=temperature, 171 weather_flag=weather_flag, 172 posted_qty=bundle.posted_qty, 173 )
Build a ForecastQuery for an upcoming bundle.
Handles all the external lookups — weather from Open-Meteo and UK bank holidays — so the caller just passes in a BundleDetails object.
Arguments:
- bundle: The basic details of the upcoming bundle.
Returns:
A fully populated ForecastQuery ready for generate_forecast().
176def fetch_historical_weather( 177 bundle_date: datetime.date, latitude: float, longitude: float 178) -> tuple[Decimal, WeatherFlag]: 179 """Fetch the actual weather conditions for a completed bundle's date. 180 181 Called after a bundle's pickup window closes, before writing the 182 forecast_input row. Uses the Open-Meteo archive endpoint which holds 183 verified historical records rather than forecast data. 184 185 Args: 186 bundle_date: The date the bundle's pickup window fell on. 187 latitude: Seller latitude. 188 longitude: Seller longitude. 189 190 Returns: 191 A (temperature, WeatherFlag) tuple of the actual conditions on that day. 192 """ 193 return _fetch_weather(_PAST_URL, bundle_date, latitude, longitude)
Fetch the actual weather conditions for a completed bundle's date.
Called after a bundle's pickup window closes, before writing the forecast_input row. Uses the Open-Meteo archive endpoint which holds verified historical records rather than forecast data.
Arguments:
- bundle_date: The date the bundle's pickup window fell on.
- latitude: Seller latitude.
- longitude: Seller longitude.
Returns:
A (temperature, WeatherFlag) tuple of the actual conditions on that day.