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)
class BundleDetails(pydantic.main.BaseModel):
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.

bundle_id: int = PydanticUndefined
bundle_date: datetime.date = PydanticUndefined
window_start: datetime.datetime = PydanticUndefined
window_end: datetime.datetime = PydanticUndefined
seller_id: int = PydanticUndefined
category_ids: list[int] = PydanticUndefined
latitude: float | None = PydanticUndefined
longitude: float | None = PydanticUndefined
posted_qty: int = PydanticUndefined
def build_forecast_query( bundle: BundleDetails) -> internal.analytics.forecast.ForecastQuery:
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().

def fetch_historical_weather( bundle_date: datetime.date, latitude: float, longitude: float) -> tuple[decimal.Decimal, internal.queries.models.WeatherFlag]:
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.