Skip to content

Commit 365849b

Browse files
fedjoarruda
andauthored
hourly weather and spray-condition forecast (#85)
* adding the push thi/fligh/spray flags to the setup of the FC jobs This should avoid issues on the fetch_or_create methods accessing position [0] in an empty list of the given activity type respose. * Treat empty location responses from FarmCalendar, handle open-meteo error when connection lost (#14) * Handle error on no internet * Handle empty json response from FC * Add a safer check on empty graph list * Add farm name and parcel to the obs title and desc * Eagerly fetch parcels from FC * Add hourly forecast endpoint * Use total precipitation (rain, showers, snow) sum of the preceding hour instead of just rain * Catch exceptions and treat missing spray values --------- Co-authored-by: Felipe Arruda Pontes <felipe.arrudapontes@maastrichtuniversity.nl>
1 parent 20dc88a commit 365849b

6 files changed

Lines changed: 378 additions & 57 deletions

File tree

src/api/api_v1/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from fastapi import APIRouter
2-
from .endpoints import auth, locations, history
2+
from .endpoints import auth, locations, history, forecast
33

44

55
api_router = APIRouter()
66
api_router.include_router(locations.router, prefix="/locations", tags=["locations"])
77
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
88
api_router.include_router(history.router, prefix="/history", tags=["history"])
9+
api_router.include_router(forecast.router, prefix="/forecast", tags=["forecast-hourly"])
910

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""
2+
Hourly forecast endpoints.
3+
4+
Returns all 24 hours of the current day (past and future) plus the
5+
following days, one record per hour. No past-hour filtering is applied
6+
so callers always receive a complete day timeline.
7+
8+
Endpoints
9+
---------
10+
GET /api/v1/forecast/hourly/
11+
Plain JSON – hourly weather data for ``days`` days (default 5).
12+
13+
GET /api/v1/forecast/hourly/spray/
14+
Hourly spray-condition assessment derived from the same hourly
15+
forecast, evaluated for every single hour (not tri-hourly).
16+
"""
17+
18+
import logging
19+
from typing import Dict, List
20+
21+
from fastapi import APIRouter, Query, HTTPException
22+
23+
from src.external_services.openmeteo import WeatherClientFactory
24+
from src.schemas.point import GeoJSONOut
25+
from src.schemas.history_data import HourlyObservationOut, HourlyResponse
26+
from src.schemas.spray import SprayForecastResponse
27+
from src.models.spray import SprayStatus
28+
from src import utils
29+
30+
logger = logging.getLogger(__name__)
31+
32+
router = APIRouter()
33+
34+
35+
# ---------------------------------------------------------------------------
36+
# Hourly weather forecast
37+
# ---------------------------------------------------------------------------
38+
39+
@router.get(
40+
"/hourly/",
41+
response_model=HourlyResponse,
42+
summary="Hourly weather data – today (full day) + next days",
43+
)
44+
async def get_hourly_forecast(
45+
lat: float = Query(..., description="Latitude", example=38.25),
46+
lon: float = Query(..., description="Longitude", example=21.74),
47+
days: int = Query(
48+
5, ge=1, le=16,
49+
description="Number of days (including today)",
50+
),
51+
):
52+
"""
53+
Returns **hourly** weather data from 00:00 of today through 23:00
54+
of the last forecast day.
55+
56+
Unlike the legacy ``/api/data/forecast5/`` endpoint:
57+
58+
* Resolution is **1 hour** (not 3 hours).
59+
* **All hours of today** are included – both past and future –
60+
so the caller always receives a complete timeline for the
61+
current day.
62+
* Data comes from the Open-Meteo Forecast API (no API key required).
63+
"""
64+
client = WeatherClientFactory.get_provider()
65+
try:
66+
results = await client.get_hourly_forecast(lat, lon, days=days)
67+
except Exception as e:
68+
logger.error(f"Error fetching hourly forecast from Open-Meteo: {e}")
69+
raise HTTPException(
70+
status_code=502,
71+
detail="Could not retrieve hourly forecast from Open-Meteo",
72+
) from e
73+
74+
return HourlyResponse(
75+
location={"lat": lat, "lon": lon},
76+
data=results,
77+
source="open-meteo",
78+
)
79+
80+
81+
# ---------------------------------------------------------------------------
82+
# Hourly spray-condition forecast
83+
# ---------------------------------------------------------------------------
84+
85+
@router.get(
86+
"/hourly/spray/",
87+
response_model=List[SprayForecastResponse],
88+
summary="Hourly spray-condition forecast – today (full day) + next days",
89+
)
90+
async def get_hourly_spray_forecast(
91+
lat: float = Query(..., description="Latitude", example=38.25),
92+
lon: float = Query(..., description="Longitude", example=21.74),
93+
days: int = Query(
94+
5, ge=1, le=16,
95+
description="Number of days (including today)",
96+
),
97+
):
98+
"""
99+
Evaluates **hourly** spray conditions from 00:00 of today through 23:00
100+
of the last forecast day.
101+
102+
Each hour is individually assessed for temperature, wind, humidity,
103+
precipitation and delta-T to produce an ``optimal``, ``marginal``,
104+
or ``unsuitable`` rating.
105+
106+
All hours of the current day (past and future) are included so the
107+
caller always gets a complete day timeline.
108+
"""
109+
client = WeatherClientFactory.get_provider()
110+
try:
111+
hourly_data = await client.get_hourly_forecast(lat, lon, days=days)
112+
except Exception as e:
113+
logger.error(f"Error fetching hourly forecast from Open-Meteo: {e}")
114+
raise HTTPException(
115+
status_code=502,
116+
detail="Could not retrieve hourly forecast from Open-Meteo",
117+
) from e
118+
119+
location = GeoJSONOut(**{"type": "Point", "coordinates": [lat, lon]})
120+
121+
results: List[SprayForecastResponse] = []
122+
for obs in hourly_data:
123+
v = obs.values
124+
temp = v.get("temperature_2m")
125+
humidity = v.get("relative_humidity_2m")
126+
wind_ms = v.get("wind_speed_10m")
127+
precipitation = v.get("precipitation", 0.0) or 0.0
128+
129+
# Instead of skipping the hour entirely if one variable is missing, we could
130+
# choose to evaluate spray conditions with the available data and mark the missing
131+
# variables in the detailed status.
132+
if temp is None or humidity is None or wind_ms is None:
133+
logger.warning(f"Missing essential weather data for hour {obs.timestamp}: temp={temp}, humidity={humidity}, wind_ms={wind_ms}")
134+
spray_condition = "unknown"
135+
status_details = {
136+
"temperature": temp if temp is not None else "missing",
137+
"humidity": humidity if humidity is not None else "missing",
138+
"wind_speed": wind_ms if wind_ms is not None else "missing",
139+
"precipitation": precipitation,
140+
}
141+
else:
142+
143+
wind_kmh = wind_ms * 3.6 # convert m/s → km/h
144+
temp_wet_bulb = utils.calculate_wet_bulb(temp, humidity)
145+
delta_t = temp - temp_wet_bulb
146+
147+
spray_condition, status_details = utils.evaluate_spray_conditions(
148+
temp, wind_kmh, precipitation, humidity, delta_t,
149+
)
150+
151+
# Convert SprayStatus enum values to strings for the dict
152+
detailed_status_str: Dict[str, str] = {
153+
k: v.value if isinstance(v, SprayStatus) else str(v)
154+
for k, v in status_details.items()
155+
}
156+
157+
results.append(
158+
SprayForecastResponse(
159+
timestamp=obs.timestamp,
160+
spray_conditions=spray_condition,
161+
source="open-meteo",
162+
location=location,
163+
detailed_status=detailed_status_str,
164+
)
165+
)
166+
167+
return results

src/core/app.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,12 @@ async def start_scheduler(app: Application):
148148
app.state.fc_client = FarmCalendarServiceClient(app)
149149
await app.state.fc_client.fetch_and_cache_locations()
150150
await app.state.fc_client.fetch_and_cache_uavs()
151-
await app.state.fc_client.fetch_or_create_thi_activity_type()
152-
await app.state.fc_client.fetch_or_create_flight_forecast_activity_type()
153-
await app.state.fc_client.fetch_or_create_spray_forecast_activity_type()
151+
if config.PUSH_THI_TO_FARMCALENDAR:
152+
await app.state.fc_client.fetch_or_create_thi_activity_type()
153+
if config.PUSH_FLIGHT_FORECAST_TO_FARMCALENDAR:
154+
await app.state.fc_client.fetch_or_create_flight_forecast_activity_type()
155+
if config.PUSH_SPRAY_F_TO_FARMCALENDAR:
156+
await app.state.fc_client.fetch_or_create_spray_forecast_activity_type()
154157

155158
await scheduler.start_scheduler(app)
156159

src/external_services/openmeteo.py

Lines changed: 108 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,55 @@
1+
import logging
2+
from fastapi import HTTPException
13
import httpx
2-
from datetime import date, datetime
3-
from typing import List, Optional, Protocol
4+
from datetime import date, datetime, timezone
5+
from typing import Dict, List, Optional, Protocol, Union
46
import os
57

68
from src.core import config
79
from src.schemas.history_data import DailyObservationOut, HourlyObservationOut
810

911

12+
logger = logging.getLogger(__name__)
13+
14+
1015
class WeatherProvider(Protocol):
11-
async def get_hourly_history(self, lat: float, lon: float, start: date, end: date, variables: List[str]) -> List[HourlyObservationOut]:
16+
async def get_hourly_history(
17+
self, lat: float, lon: float, start: date, end: date, variables: List[str]
18+
) -> List[HourlyObservationOut]:
1219
...
1320

14-
async def get_daily_history(self, lat: float, lon: float, start: date, end: date, variables: List[str]) -> List[DailyObservationOut]:
21+
async def get_daily_history(
22+
self, lat: float, lon: float, start: date, end: date, variables: List[str]
23+
) -> List[DailyObservationOut]:
24+
...
25+
26+
async def get_hourly_forecast(
27+
self, lat: float, lon: float, days: int = 5
28+
) -> List[HourlyObservationOut]:
1529
...
1630

1731

1832
class OpenMeteoClient:
1933
BASE_URL = "https://archive-api.open-meteo.com/v1/archive"
34+
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
35+
36+
async def _fetch_data(self, params: dict, url: Optional[str] = None) -> dict:
37+
"""Fetch data from Open-Meteo API with error handling."""
38+
target_url = url or self.BASE_URL
39+
try:
40+
async with httpx.AsyncClient() as client:
41+
response = await client.get(target_url, params=params, timeout=10.0)
42+
response.raise_for_status()
43+
return response.json()
44+
except (httpx.NetworkError, httpx.ConnectError, httpx.TimeoutException) as e:
45+
logger.error(f"Internet connection is not available: {e}")
46+
raise HTTPException(
47+
status_code=503,
48+
detail="Internet connection is not available. Please cache data for the specified location"
49+
) from e
50+
except httpx.HTTPStatusError as e:
51+
logger.error(f"HTTP error: {e}")
52+
raise e
2053

2154
async def get_hourly_history(self, lat: float, lon: float, start: date, end: date, variables: List[str]) -> List[HourlyObservationOut]:
2255
params = {
@@ -28,11 +61,7 @@ async def get_hourly_history(self, lat: float, lon: float, start: date, end: dat
2861
"end_date": end.isoformat()
2962
}
3063

31-
async with httpx.AsyncClient() as client:
32-
response = await client.get(self.BASE_URL, params=params)
33-
response.raise_for_status()
34-
data = response.json()
35-
64+
data = await self._fetch_data(params)
3665
timestamps = data["hourly"]["time"]
3766
results = []
3867

@@ -52,11 +81,7 @@ async def get_daily_history(self, lat: float, lon: float, start: date, end: date
5281
"end_date": end.isoformat()
5382
}
5483

55-
async with httpx.AsyncClient() as client:
56-
response = await client.get(self.BASE_URL, params=params)
57-
response.raise_for_status()
58-
data = response.json()
59-
84+
data = await self._fetch_data(params)
6085
timestamps = data["daily"]["time"]
6186
results = []
6287

@@ -71,6 +96,75 @@ async def get_single_day_history(self, lat: float, lon: float, day: date, variab
7196
daily = await self.get_daily_history(lat, lon, day, day, variables.get("daily", []))
7297
return hourly, daily
7398

99+
# ---- Hourly forecast (Open-Meteo Forecast API) ----
100+
101+
# The list of hourly variables requested from the Open-Meteo Forecast API.
102+
HOURLY_FORECAST_VARIABLES = [
103+
"temperature_2m",
104+
"relative_humidity_2m",
105+
"dew_point_2m",
106+
"apparent_temperature",
107+
"precipitation",
108+
"rain",
109+
"snowfall",
110+
"snow_depth",
111+
"pressure_msl",
112+
"surface_pressure",
113+
"cloud_cover",
114+
"wind_speed_10m",
115+
"wind_direction_10m",
116+
"wind_gusts_10m",
117+
"visibility",
118+
"uv_index",
119+
]
120+
121+
async def get_hourly_forecast(
122+
self, lat: float, lon: float, days: int = 5
123+
) -> List[HourlyObservationOut]:
124+
"""
125+
Fetch hourly weather data for today (all 24 h, past + future) plus
126+
the next ``days−1`` days from the Open-Meteo **Forecast** API.
127+
128+
Returns one :class:`HourlyObservationOut` per hour (up to ``days * 24``
129+
entries). Past hours of the current day are **never** filtered out so
130+
the caller always gets a complete day timeline.
131+
"""
132+
params = {
133+
"latitude": lat,
134+
"longitude": lon,
135+
"hourly": ",".join(self.HOURLY_FORECAST_VARIABLES),
136+
"timezone": "auto",
137+
"past_days": 0,
138+
"forecast_days": days,
139+
}
140+
141+
data = await self._fetch_data(params, url=self.FORECAST_URL)
142+
143+
if "hourly" not in data:
144+
logger.warning("Open-Meteo returned no hourly forecast data")
145+
return []
146+
147+
timestamps = data["hourly"]["time"]
148+
results: List[HourlyObservationOut] = []
149+
150+
for i, t in enumerate(timestamps):
151+
values: Dict[str, Union[float, None]] = {}
152+
for var in self.HOURLY_FORECAST_VARIABLES:
153+
if var in data["hourly"]:
154+
values[var] = data["hourly"][var][i]
155+
results.append(
156+
HourlyObservationOut(
157+
timestamp=datetime.fromisoformat(t),
158+
values=values,
159+
)
160+
)
161+
162+
logger.info(
163+
"Open-Meteo hourly forecast: %d hours for (%s, %s), %d days",
164+
len(results), lat, lon, days,
165+
)
166+
return results
167+
74168

75169
# Factory using environment variable
76170
class WeatherClientFactory:

0 commit comments

Comments
 (0)