Skip to content

Commit 2d9dc69

Browse files
authored
Merge pull request #75 from openagri-eu/feature/upstream-updates
Sync OA for with AgStack upstream
2 parents 923b80e + 3988156 commit 2d9dc69

6 files changed

Lines changed: 267 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ The Weather Service provides:
2222
- **Agricultural indicators** – Temperature-Humidity Index (THI)
2323
- **UAV flight forecasts** – 5-day predictions for UAV flight conditions (by model, filterable)
2424
- **Spray condition forecasts** – support for agricultural spraying planning
25-
- **Historical weather API** – daily and hourly values
25+
- **Historical weather API** – daily and hourly values. You can find more info [here](history.md)
2626
- **Offline support** – cache last month’s history for specific locations
2727
- **Containerized builds** – multi-arch Docker images (AMD64 and ARM64)
2828

history.md

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
# Weather Data Caching & History API
2+
3+
This release introduces endpoints for **cached location management** and **historical weather data retrieval**.
4+
It enables local caching, scheduled updates, and seamless fallback to external services (Open-Meteo) — ensuring data availability even in low-connectivity environments.
5+
6+
---
7+
8+
## 1. Cached Location Management API
9+
10+
The system maintains a **cache of locations** for which weather history data are stored locally and periodically updated.
11+
Each cached location automatically schedules a background job that fetches new data daily and removes the oldest record — maintaining a **sliding 30-day window** of historical weather.
12+
13+
### Add a Location (Unique Cache)
14+
15+
**Endpoint:**
16+
`POST /api/v1/locations/unique`
17+
18+
**Description:**
19+
Adds a new location to the cache, **skipping duplicates** if another cached location already exists within a configurable proximity radius.
20+
21+
This mechanism avoids redundant storage for nearby points that share the same climatic conditions.
22+
23+
**Example Request**
24+
25+
```json
26+
{
27+
"locations": [
28+
{
29+
"name": "Farm A",
30+
"lat": 35.0,
31+
"lon": 33.2
32+
}
33+
]
34+
}
35+
```
36+
37+
**Example Response**
38+
39+
```json
40+
[
41+
{
42+
"id": "64f123abc456...",
43+
"name": "Farm A",
44+
"lat": 35.0,
45+
"lon": 33.2,
46+
"created_at": "2025-08-08T14:23:11.123Z"
47+
}
48+
]
49+
```
50+
51+
### Why Radius-Based Deduplication?
52+
53+
Nearby locations (within 5–10 km) in flat or coastal areas typically experience **identical weather patterns**.
54+
Using a proximity radius ensures the system:
55+
- Minimizes redundant data storage.
56+
- Reduces API calls to external providers.
57+
- Improves performance and cost efficiency.
58+
59+
#### Works Well For:
60+
- Use cases where approximate data are acceptable (e.g., irrigation scheduling, pest/disease models).
61+
- Plains, coastal regions, or small islands where local variability is low.
62+
63+
#### Limitations:
64+
- Mountainous or heterogeneous terrain (e.g., high elevation gradients, valley–ridge differences) may show **localized microclimates** where nearby coordinates differ significantly.
65+
- In such cases, consider **reducing the radius** or **explicitly caching** separate points.
66+
67+
#### Future Alternatives:
68+
- Dynamically adapt `radius_km` based on topography or [Köppen climate zones](https://koeppen-geiger.vu-wien.ac.at/present.htm).
69+
- Introduce **grid-based caching** or **spatial clustering** using geohashes.
70+
71+
---
72+
73+
## 2. Historical Weather Data API
74+
75+
Once a location is cached, you can fetch its historical data either from the **local database** or via **Open-Meteo fallback** if no cached record exists.
76+
77+
### Daily Weather Observations
78+
79+
**Endpoint:**
80+
`POST /api/v1/history/daily`
81+
82+
**Description:**
83+
Retrieve **daily weather summaries** for a given coordinate and date range.
84+
If cached data exist within the specified radius, they are returned directly; otherwise, data are fetched via Open-Meteo and optionally cached.
85+
86+
**Request**
87+
88+
```json
89+
{
90+
"lat": 35.0,
91+
"lon": 33.2,
92+
"start": "2025-07-01",
93+
"end": "2025-07-03",
94+
"variables": ["temperature_2m_max", "temperature_2m_min"],
95+
"radius_km": 10
96+
}
97+
```
98+
99+
**Response**
100+
101+
```json
102+
{
103+
"location": { "lat": 35.0, "lon": 33.2 },
104+
"data": [
105+
{
106+
"date": "2025-07-01",
107+
"values": {
108+
"temperature_2m_max": 32.1,
109+
"temperature_2m_min": 22.4
110+
}
111+
},
112+
{
113+
"date": "2025-07-02",
114+
"values": {
115+
"temperature_2m_max": 33.3,
116+
"temperature_2m_min": 21.8
117+
}
118+
}
119+
],
120+
"source": "open-meteo"
121+
}
122+
```
123+
124+
---
125+
126+
### Hourly Weather Observations
127+
128+
**Endpoint:**
129+
`POST /api/v1/history/hourly`
130+
131+
**Description:**
132+
Retrieve **hourly data** for the given coordinates and time range.
133+
Data are served from the cache if available, otherwise fetched from Open-Meteo.
134+
135+
**Request**
136+
137+
```json
138+
{
139+
"lat": 35.0,
140+
"lon": 33.2,
141+
"start": "2025-07-01",
142+
"end": "2025-07-03",
143+
"variables": ["temperature_2m", "relative_humidity_2m"],
144+
"radius_km": 10
145+
}
146+
```
147+
148+
**Response (cached data)**
149+
150+
```json
151+
{
152+
"location": { "lat": 35.0, "lon": 33.2 },
153+
"data": [
154+
{
155+
"timestamp": "2025-07-01T00:00:00Z",
156+
"values": {
157+
"temperature_2m": 26.3,
158+
"relative_humidity_2m": 65
159+
}
160+
}
161+
],
162+
"source": "open-meteo"
163+
}
164+
```
165+
166+
---
167+
168+
## Background Sliding Window Updates
169+
170+
Each cached location has a **dedicated background job** that:
171+
- Fetches **yesterday’s data daily** when an internet connection is available.
172+
- **Removes the oldest record** to maintain a fixed 30-day window.
173+
- **Stores all results locally** in the database.
174+
175+
This ensures that:
176+
- **Offline access** is possible even without external APIs.
177+
- Data remain **up-to-date** automatically when connectivity returns.
178+
179+
---
180+
181+
## Why This Matters
182+
183+
This design enables hybrid online/offline behavior:
184+
- **Connected mode:** Data are synced automatically with Open-Meteo.
185+
- **Offline mode:** Cached history remains queryable, ideal for rural or remote agricultural deployments.
186+
187+
---
188+
189+
## Example Workflow
190+
191+
1. Add a location for caching:
192+
```bash
193+
curl -X POST http://localhost:8000/api/v1/locations/unique -H "Content-Type: application/json" -d '{"locations": [{"lat": 35.0, "lon": 33.2}], "radius_km": 10}'
194+
```
195+
196+
2. Fetch daily history:
197+
```bash
198+
curl -X POST http://localhost:8000/api/v1/history/daily -H "Content-Type: application/json" -d '{"lat": 35.0, "lon": 33.2, "start": "2025-07-01", "end": "2025-07-03", "variables": ["temperature_2m_max", "temperature_2m_min"], "radius_km": 10}'
199+
```
200+
201+
3. Fetch hourly history:
202+
```bash
203+
curl -X POST http://localhost:8000/api/v1/history/hourly -H "Content-Type: application/json" -d '{"lat": 35.0, "lon": 33.2, "start": "2025-07-01", "end": "2025-07-03", "variables": ["temperature_2m", "relative_humidity_2m"], "radius_km": 10}'
204+
```
205+
206+
## Available Variables
207+
208+
These variables can be requested in the `variables` array of `/history/daily` or `/history/hourly` requests.
209+
210+
### Daily Variables
211+
| Variable | Description |
212+
|-----------|--------------|
213+
| temperature_2m_min | Minimum daily temperature at 2 meters |
214+
| temperature_2m_max | Maximum daily temperature at 2 meters |
215+
| temperature_2m_mean | Mean daily temperature at 2 meters |
216+
| precipitation_sum | Total precipitation (rain + other forms) |
217+
| rain_sum | Total rainfall only |
218+
| wind_speed_10m_max | Maximum wind speed at 10 meters |
219+
| wind_gusts_10m_max | Maximum wind gusts at 10 meters |
220+
| wind_direction_10m_dominant | Dominant wind direction at 10 meters |
221+
222+
### Hourly Variables
223+
| Variable | Description |
224+
|-----------|--------------|
225+
| temperature_2m | Air temperature at 2 meters |
226+
| relative_humidity_2m | Relative humidity at 2 meters |
227+
| precipitation | Hourly precipitation total |
228+
| rain | Hourly rainfall total |
229+
| pressure_msl | Mean sea level pressure |
230+
| surface_pressure | Atmospheric pressure at surface |
231+
| cloud_cover | Total cloud cover (%) |
232+
| et0_fao_evapotranspiration | Reference evapotranspiration (FAO Penman-Monteith) |
233+
| wind_speed_10m | Wind speed at 10 meters |
234+
| wind_direction_10m | Wind direction at 10 meters |
235+
| wind_gusts_10m | Wind gusts at 10 meters |
236+
| soil_temperature_0_to_7cm | Soil temperature (0–7 cm depth) |
237+
| soil_temperature_7_to_28cm | Soil temperature (7–28 cm depth) |
238+
| soil_temperature_28_to_100cm | Soil temperature (28–100 cm depth) |
239+
| soil_temperature_100_to_255cm | Soil temperature (100–255 cm depth) |
240+
| soil_moisture_0_to_7cm | Soil moisture (0–7 cm depth) |
241+
| soil_moisture_7_to_28cm | Soil moisture (7–28 cm depth) |
242+
| soil_moisture_28_to_100cm | Soil moisture (28–100 cm depth) |
243+
| soil_moisture_100_to_255cm | Soil moisture (100–255 cm depth) |
244+

src/external_services/interoperability.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ class InteroperabilitySchema:
8181
},
8282
'precipitation': {
8383
'measurement': 'Precipitation',
84+
'unit': 'Percent',
85+
},
86+
'rainfall_3h': {
87+
'measurement': 'Rainfall_3h',
8488
'unit': 'Millimetre',
8589
},
8690
}

src/external_services/openweathermap.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime, timezone
1+
from datetime import datetime, timedelta, timezone
22
import logging
33
from typing import List, Optional, Union
44

@@ -46,7 +46,8 @@ class OpenWeatherMap():
4646
'ambient_humidity': ['main', 'humidity'],
4747
'wind_speed': ['wind', 'speed'],
4848
'wind_direction': ['wind', 'deg'],
49-
'precipitation': ['rain', '3h'],
49+
'precipitation': ['pop'],
50+
'rainfall_3h': ['rain', '3h'],
5051
}
5152
},
5253
}
@@ -244,6 +245,8 @@ async def ensure_forecast_for_uavs_and_location(
244245

245246

246247
now = datetime.now(timezone.utc)
248+
# Default cache time of UAV forecast
249+
hours_ago = datetime.utcnow() - timedelta(hours=config.CURRENT_WEATHER_DATA_CACHE_TIME)
247250
results = []
248251

249252
# Check if any model needs forecast data
@@ -252,8 +255,9 @@ async def ensure_forecast_for_uavs_and_location(
252255
existing = await FlyStatus.find(And(
253256
(FlyStatus.uav_model == model),
254257
(FlyStatus.location == point.location),
255-
(FlyStatus.timestamp > now))
256-
).to_list()
258+
(FlyStatus.timestamp > now),
259+
(FlyStatus.created_at >= hours_ago),
260+
)).to_list()
257261
if not existing:
258262
models_to_fetch.append(model)
259263
else:
@@ -303,10 +307,13 @@ async def ensure_spray_forecast_for_location(self, lat, lon, return_existing=Tru
303307

304308
point = await self.dao.find_or_create_point(lat, lon)
305309
now = datetime.now()
310+
# Default cache time of spray forecast
311+
hours_ago = datetime.utcnow() - timedelta(hours=config.CURRENT_WEATHER_DATA_CACHE_TIME)
306312

307313
results = await SprayForecast.find(And(
308314
SprayForecast.timestamp > now,
309-
SprayForecast.location == point.location
315+
SprayForecast.location == point.location,
316+
SprayForecast.created_at >= hours_ago,
310317
)).to_list()
311318

312319
if results:
@@ -372,7 +379,7 @@ async def parseForecast5dayResponse(self, point: Point, data: dict) -> List[Pred
372379
extracted_element['period'][key] = utils.extract_value_from_dict_path(e, path)
373380
for key, path in self.properties['extracted_schema']['measurements'].items():
374381
extracted_element['measurements'][key] = utils.extract_value_from_dict_path(e, path)
375-
if not extracted_element['measurements'][key]:
382+
if extracted_element['measurements'][key] == None:
376383
continue
377384
prediction = await Prediction(
378385
value=extracted_element['measurements'][key],

src/models/spray.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Dict
44

55
from beanie import Document
6+
from pydantic import Field
67

78
from src.models.point import GeoJSON
89

@@ -14,6 +15,7 @@ class SprayStatus(str, Enum):
1415

1516

1617
class SprayForecast(Document):
18+
created_at: datetime = Field(default_factory=datetime.now)
1719
timestamp: datetime
1820
source: str
1921
location: GeoJSON

src/models/uav.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Dict
44

55
from beanie import Document
6+
from pydantic import Field
67

78
from src.models.point import GeoJSON
89

@@ -13,6 +14,7 @@ class FlightStatus(str, Enum):
1314
MARGINAL = "MARGINAL"
1415

1516
class FlyStatus(Document):
17+
created_at: datetime = Field(default_factory=datetime.now)
1618
timestamp: datetime
1719
uav_model: str
1820
status: FlightStatus # OK, NOT OK, MARGINAL
@@ -24,6 +26,7 @@ class Config:
2426
use_enum_values = True
2527
json_schema_extra = {
2628
"example": {
29+
"created_at": "2024-10-30T09:00:00+00:00",
2730
"timestamp": "2024-11-01T09:00:00+00:00",
2831
"uav_model": "DJI Mavic Air 2",
2932
"status": "good",

0 commit comments

Comments
 (0)