Skip to content

weather

weather #

Weather forecasting via the Open-Meteo API.

Fetches hourly weather forecasts from the Open-Meteo API (free, no API key required), plus fetch_past_weather for historical backfill via Meteostat. Both return the canonical cg.Weather.

fetch_past_weather(location, dt) #

Fetch historical weather for a location and time using Meteostat.

Useful for backfilling weather on captures that did not record it. Fields Meteostat does not provide (apparent temperature, visibility, UV) come back as nan.

Parameters:

Name Type Description Default
location Location

Location with latitude, longitude, and altitude.

required
dt datetime

Date and time to fetch weather for (naive is treated as local).

required

Returns:

Type Description
Weather

A cg.Weather with the available observations.

Raises:

Type Description
ImportError

If meteostat is not installed.

ValueError

If no weather data is available for the location/time.

Example
import capturegraph as cg
from capturegraph.scheduling.forecast.weather import fetch_past_weather
from datetime import datetime

loc = cg.Location(latitude=42.445, longitude=-76.480, altitude_meters=261.5)
weather = fetch_past_weather(loc, datetime(2024, 6, 15, 12, 0))
weather.temperature_celsius  # 22.5
Source code in capturegraph-lib/capturegraph/scheduling/forecast/weather.py
def fetch_past_weather(location: cg.Location, dt: datetime) -> cg.Weather:
    """Fetch historical weather for a location and time using Meteostat.

    Useful for backfilling weather on captures that did not record it. Fields
    Meteostat does not provide (apparent temperature, visibility, UV) come back
    as ``nan``.

    Args:
        location: Location with latitude, longitude, and altitude.
        dt: Date and time to fetch weather for (naive is treated as local).

    Returns:
        A ``cg.Weather`` with the available observations.

    Raises:
        ImportError: If meteostat is not installed.
        ValueError: If no weather data is available for the location/time.

    Example:
        ```python
        import capturegraph as cg
        from capturegraph.scheduling.forecast.weather import fetch_past_weather
        from datetime import datetime

        loc = cg.Location(latitude=42.445, longitude=-76.480, altitude_meters=261.5)
        weather = fetch_past_weather(loc, datetime(2024, 6, 15, 12, 0))
        weather.temperature_celsius  # 22.5
        ```
    """
    try:
        import meteostat as ms
    except ImportError as error:
        raise ImportError(
            "fetch_past_weather requires meteostat. Install with: pip install meteostat"
        ) from error

    # Meteostat accepts a float elevation in metres; its stub over-narrows to int.
    point = ms.Point(location.latitude, location.longitude, location.altitude_meters)  # pyright: ignore[reportArgumentType]
    stations = ms.stations.nearby(point, limit=4)

    start = dt.replace(hour=0, minute=0, second=0, microsecond=0)
    end = dt.replace(hour=23, minute=59, second=59, microsecond=0)
    timeseries = ms.hourly(stations, start, end)
    df = ms.interpolate(timeseries, point).fetch()

    if df is None or df.empty:
        raise ValueError(
            f"No weather data available for "
            f"({location.latitude}, {location.longitude}) on {dt.date()}"
        )

    target_hour = dt.replace(minute=0, second=0, microsecond=0)
    if target_hour in df.index:
        row = df.loc[target_hour]
    else:
        row = df.iloc[df.index.get_indexer([target_hour], method="nearest")[0]]

    def value(name: str, default: float) -> float:
        raw = row.get(name)
        return default if raw is None or pd.isna(raw) else raw

    temp = value("temp", 0.0)
    dwpt = value("dwpt", temp)
    rhum = value("rhum", 50.0)
    pres = value("pres", 1013.25)
    wspd = value("wspd", float("nan"))  # km/h
    wpgt = value("wpgt", float("nan"))  # gust km/h
    wdir = value("wdir", 0.0)
    prcp = value("prcp", 0.0)
    coco = value("coco", 1)
    cldc = value("cldc", float("nan"))  # oktas (0-8)
    tsun = value("tsun", float("nan"))  # minutes

    if not pd.isna(cldc):
        cloud_cover_ratio = float(cldc) / 8.0
    else:
        cloud_cover_ratio = 0.5 if coco and int(coco) >= 3 else 0.0

    condition = _PAST_CONDITIONS.get(int(coco) if coco else 1, "Unknown")
    # bool(...) drops numpy's np.bool_ (tsun is a pandas value), which the
    # Weather codec rejects for a bool field.
    is_daylight = bool(tsun > 0) if not pd.isna(tsun) else (6 <= dt.hour <= 20)

    return cg.Weather(
        temperature_celsius=float(temp),
        apparent_temperature_celsius=float("nan"),
        dew_point_celsius=float(dwpt),
        humidity_ratio=float(rhum) / 100.0,
        pressure_hpa=float(pres),
        wind_speed_mps=wspd / 3.6 if wspd else 0.0,
        wind_direction_degrees=float(wdir),
        condition=condition,
        symbol_name=_PAST_SYMBOLS.get(condition, "cloud.fill"),
        cloud_cover_ratio=cloud_cover_ratio,
        precipitation_intensity_mmph=float(prcp),
        visibility_meters=float("nan"),
        uv_index=float("nan"),
        is_daylight=is_daylight,
        time=dt,
        wind_gust_mps=wpgt / 3.6 if wpgt else None,
    )

hourly_weather(location, days=3) #

Fetch hourly weather forecast from Open-Meteo.

Parameters:

Name Type Description Default
location Location

Location with latitude and longitude attributes.

required
days int

Number of days to forecast (1-16). Defaults to 3.

3

Returns:

Type Description
Array[Weather]

List of Weather objects for each hour.

Source code in capturegraph-lib/capturegraph/scheduling/forecast/weather.py
def hourly_weather(
    location: cg.Location,
    days: int = 3,
) -> cg.Array[cg.Weather]:
    """Fetch hourly weather forecast from Open-Meteo.

    Args:
        location: Location with latitude and longitude attributes.
        days: Number of days to forecast (1-16). Defaults to 3.

    Returns:
        List of Weather objects for each hour.
    """
    import openmeteo_requests
    import requests_cache
    from retry_requests import retry

    # Setup cached session with retry
    cache_session = requests_cache.CachedSession(".cache", expire_after=3600)
    retry_session = retry(cache_session, retries=5, backoff_factor=0.2)
    # The openmeteo client's stub types ``session`` as its own Session class, which
    # the retry-wrapped CachedSession does not statically match; it works at runtime.
    openmeteo = openmeteo_requests.Client(session=retry_session)  # pyright: ignore[reportArgumentType]

    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": location.latitude,
        "longitude": location.longitude,
        "forecast_days": min(days, 16),
        "hourly": [
            "temperature_2m",
            "apparent_temperature",
            "dew_point_2m",
            "relative_humidity_2m",
            "pressure_msl",
            "wind_speed_10m",
            "wind_gusts_10m",
            "wind_direction_10m",
            "cloud_cover",
            "precipitation",
            "visibility",
            "uv_index",
            "is_day",
            "weather_code",
        ],
    }

    responses = openmeteo.weather_api(url, params=params)
    response = responses[0]

    hourly = response.Hourly()
    if hourly is None:
        raise RuntimeError("Open-Meteo response returned no hourly data")

    def values(index: int) -> np.ndarray:
        variable = hourly.Variables(index)
        if variable is None:
            raise RuntimeError(f"Open-Meteo hourly variable {index} is missing")
        return variable.ValuesAsNumpy()

    # Extract variables in the same order as requested
    temp = values(0)
    apparent_temp = values(1)
    dew_point = values(2)
    humidity = values(3)
    pressure = values(4)
    wind_speed = values(5)
    wind_gust = values(6)
    wind_dir = values(7)
    cloud_cover = values(8)
    precip = values(9)
    visibility = values(10)
    uv_index = values(11)
    is_day = values(12)
    weather_code = values(13)

    # Build datetime index
    dates = pd.date_range(
        start=pd.to_datetime(hourly.Time(), unit="s", utc=True),
        end=pd.to_datetime(hourly.TimeEnd(), unit="s", utc=True),
        freq=pd.Timedelta(seconds=hourly.Interval()),
        inclusive="left",
    )

    forecasts = []
    for i, dt in enumerate(dates):
        weather = cg.Weather(
            temperature_celsius=float(temp[i]),
            apparent_temperature_celsius=float(apparent_temp[i]),
            dew_point_celsius=float(dew_point[i]),
            humidity_ratio=float(humidity[i]) / 100.0,
            pressure_hpa=float(pressure[i]),
            wind_speed_mps=float(wind_speed[i]) / 3.6,  # km/h to m/s
            wind_gust_mps=float(wind_gust[i]) / 3.6,
            wind_direction_degrees=float(wind_dir[i]),
            condition=_weather_code_to_condition(int(weather_code[i])),
            symbol_name=_weather_code_to_symbol(int(weather_code[i])),
            cloud_cover_ratio=float(cloud_cover[i]) / 100.0,
            precipitation_intensity_mmph=float(precip[i]),
            visibility_meters=float(visibility[i]),
            uv_index=float(uv_index[i]),
            is_daylight=bool(is_day[i]),
            time=dt.to_pydatetime().replace(tzinfo=None),
        )
        forecasts.append(weather)

    return cg.Array(forecasts)

nearest_weather(weather, times) #

Find the nearest weather entry for each timestamp.

Sorts the weather list by capture time and uses binary search to find the closest weather entry for each query time.

Parameters:

Name Type Description Default
weather Array[Weather]

List of Weather objects with time attributes.

required
times Array[datetime]

List of datetimes to find weather for.

required

Returns:

Type Description
Array[Weather]

List of Weather objects, one for each query time.

Source code in capturegraph-lib/capturegraph/scheduling/forecast/weather.py
def nearest_weather(
    weather: cg.Array[cg.Weather],
    times: cg.Array[datetime],
) -> cg.Array[cg.Weather]:
    """Find the nearest weather entry for each timestamp.

    Sorts the weather list by capture time and uses binary search to find the
    closest weather entry for each query time.

    Args:
        weather: List of Weather objects with time attributes.
        times: List of datetimes to find weather for.

    Returns:
        List of Weather objects, one for each query time.
    """
    import bisect

    # Sort weather by timestamp
    sorted_weather = sorted(weather, key=lambda w: w.time)
    timestamps = [w.time for w in sorted_weather]

    results = []
    for t in times:
        # Find insertion point
        idx = bisect.bisect_left(timestamps, t)

        # Handle edge cases
        if idx == 0:
            nearest = sorted_weather[0]
        elif idx == len(sorted_weather):
            nearest = sorted_weather[-1]
        else:
            # Compare distances to left and right neighbors
            left = sorted_weather[idx - 1]
            right = sorted_weather[idx]
            if abs(t - left.time) <= abs(right.time - t):
                nearest = left
            else:
                nearest = right

        results.append(nearest)

    return cg.Array(results)