UPDATE
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import date, timedelta
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError: # pragma: no cover - handled when live adapter is used
|
||||
requests = None
|
||||
|
||||
|
||||
DEFAULT_FORECAST_DAYS = 7
|
||||
DAILY_FIELDS = [
|
||||
"temperature_2m_max",
|
||||
"temperature_2m_min",
|
||||
"temperature_2m_mean",
|
||||
"precipitation_sum",
|
||||
"precipitation_probability_max",
|
||||
"relative_humidity_2m_mean",
|
||||
"wind_speed_10m_max",
|
||||
"et0_fao_evapotranspiration",
|
||||
"weather_code",
|
||||
]
|
||||
WMO_CODES = [0, 1, 2, 3, 45, 51, 61, 63, 65, 80, 95]
|
||||
|
||||
|
||||
def _clamp(value: float, lower: float, upper: float) -> float:
|
||||
return max(lower, min(upper, value))
|
||||
|
||||
|
||||
class BaseWeatherAdapter(ABC):
|
||||
source_name = "base"
|
||||
|
||||
@abstractmethod
|
||||
def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict:
|
||||
"""Return daily forecast data in Open-Meteo compatible shape."""
|
||||
|
||||
|
||||
class OpenMeteoWeatherAdapter(BaseWeatherAdapter):
|
||||
source_name = "open-meteo"
|
||||
|
||||
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict:
|
||||
if requests is None:
|
||||
raise RuntimeError("requests package is required for OpenMeteoWeatherAdapter")
|
||||
|
||||
params = {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"forecast_days": days,
|
||||
"timezone": "auto",
|
||||
"daily": DAILY_FIELDS,
|
||||
}
|
||||
headers = {"accept": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
response = requests.get(
|
||||
self.base_url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
class MockWeatherAdapter(BaseWeatherAdapter):
|
||||
source_name = "mock"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
delay_seconds: float = 0.8,
|
||||
seed_namespace: str = "croplogic-weather",
|
||||
):
|
||||
self.delay_seconds = max(0.0, delay_seconds)
|
||||
self.seed_namespace = seed_namespace
|
||||
|
||||
def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict:
|
||||
if self.delay_seconds:
|
||||
time.sleep(self.delay_seconds)
|
||||
|
||||
climate = self._layered_noise(latitude, longitude, "climate")
|
||||
humidity_bias = self._layered_noise(latitude, longitude, "humidity")
|
||||
rain_bias = self._layered_noise(latitude, longitude, "rain")
|
||||
wind_bias = self._layered_noise(latitude, longitude, "wind")
|
||||
temp_bias = self._layered_noise(latitude, longitude, "temp")
|
||||
|
||||
start = date.today()
|
||||
payload = {field: [] for field in DAILY_FIELDS}
|
||||
payload["time"] = []
|
||||
|
||||
for day_index in range(days):
|
||||
current_date = start + timedelta(days=day_index)
|
||||
seasonal_wave = math.sin(((current_date.timetuple().tm_yday / 365.0) * math.tau) - 0.55)
|
||||
daily_wave = math.sin((day_index / max(days, 1)) * math.tau)
|
||||
short_term = self._layered_noise(
|
||||
latitude + (day_index * 0.11),
|
||||
longitude - (day_index * 0.09),
|
||||
f"day:{day_index}",
|
||||
)
|
||||
|
||||
temp_mean = _clamp(
|
||||
17.0
|
||||
+ (seasonal_wave * 11.0)
|
||||
+ ((temp_bias - 0.5) * 8.0)
|
||||
+ (daily_wave * 2.8)
|
||||
+ ((short_term - 0.5) * 2.5),
|
||||
-6.0,
|
||||
43.0,
|
||||
)
|
||||
diurnal_range = _clamp(
|
||||
8.0 + ((1 - humidity_bias) * 4.2) + ((1 - rain_bias) * 2.0) + (short_term * 1.1),
|
||||
5.0,
|
||||
16.0,
|
||||
)
|
||||
temperature_max = _clamp(temp_mean + (diurnal_range / 2.0), -3.0, 48.0)
|
||||
temperature_min = _clamp(temp_mean - (diurnal_range / 2.0), -12.0, 35.0)
|
||||
|
||||
humidity_mean = _clamp(
|
||||
34.0
|
||||
+ (humidity_bias * 34.0)
|
||||
+ (rain_bias * 12.0)
|
||||
- ((temperature_max - 22.0) * 0.9),
|
||||
18.0,
|
||||
96.0,
|
||||
)
|
||||
precipitation_probability = _clamp(
|
||||
10.0
|
||||
+ (rain_bias * 45.0)
|
||||
+ ((humidity_mean - 45.0) * 0.45)
|
||||
+ (max(0.0, 0.5 - temp_bias) * 18.0)
|
||||
+ ((short_term - 0.5) * 18.0),
|
||||
0.0,
|
||||
100.0,
|
||||
)
|
||||
precipitation = self._precipitation_amount(
|
||||
precipitation_probability=precipitation_probability,
|
||||
rain_bias=rain_bias,
|
||||
humidity_mean=humidity_mean,
|
||||
short_term=short_term,
|
||||
)
|
||||
wind_speed = _clamp(
|
||||
8.0
|
||||
+ (wind_bias * 17.0)
|
||||
+ ((1 - rain_bias) * 2.5)
|
||||
+ (abs(daily_wave) * 3.0)
|
||||
+ (short_term * 2.0),
|
||||
3.0,
|
||||
42.0,
|
||||
)
|
||||
et0 = _clamp(
|
||||
1.0
|
||||
+ (max(temp_mean, 0.0) * 0.11)
|
||||
+ ((1 - (humidity_mean / 100.0)) * 1.7)
|
||||
+ (wind_speed * 0.03)
|
||||
- (precipitation * 0.05),
|
||||
0.3,
|
||||
11.0,
|
||||
)
|
||||
weather_code = self._weather_code(
|
||||
precipitation=precipitation,
|
||||
probability=precipitation_probability,
|
||||
humidity=humidity_mean,
|
||||
wind_speed=wind_speed,
|
||||
cloudiness=(humidity_bias + rain_bias + (1 - temp_bias)) / 3.0,
|
||||
)
|
||||
|
||||
payload["time"].append(current_date.isoformat())
|
||||
payload["temperature_2m_max"].append(round(temperature_max, 1))
|
||||
payload["temperature_2m_min"].append(round(temperature_min, 1))
|
||||
payload["temperature_2m_mean"].append(round(temp_mean, 1))
|
||||
payload["precipitation_sum"].append(round(precipitation, 1))
|
||||
payload["precipitation_probability_max"].append(round(precipitation_probability, 0))
|
||||
payload["relative_humidity_2m_mean"].append(round(humidity_mean, 1))
|
||||
payload["wind_speed_10m_max"].append(round(wind_speed, 1))
|
||||
payload["et0_fao_evapotranspiration"].append(round(et0, 2))
|
||||
payload["weather_code"].append(weather_code)
|
||||
|
||||
return {"latitude": latitude, "longitude": longitude, "daily": payload}
|
||||
|
||||
def _precipitation_amount(
|
||||
self,
|
||||
precipitation_probability: float,
|
||||
rain_bias: float,
|
||||
humidity_mean: float,
|
||||
short_term: float,
|
||||
) -> float:
|
||||
trigger = precipitation_probability / 100.0
|
||||
if trigger < 0.24:
|
||||
return 0.0
|
||||
|
||||
amount = (
|
||||
((trigger - 0.2) ** 1.55) * 18.0
|
||||
+ (rain_bias * 1.6)
|
||||
+ ((humidity_mean - 50.0) * 0.035)
|
||||
+ (short_term * 1.3)
|
||||
)
|
||||
return _clamp(amount, 0.0, 34.0)
|
||||
|
||||
def _weather_code(
|
||||
self,
|
||||
precipitation: float,
|
||||
probability: float,
|
||||
humidity: float,
|
||||
wind_speed: float,
|
||||
cloudiness: float,
|
||||
) -> int:
|
||||
if precipitation >= 10:
|
||||
return 65
|
||||
if precipitation >= 4:
|
||||
return 63
|
||||
if precipitation > 0.6:
|
||||
return 61
|
||||
if probability >= 65 and humidity >= 70:
|
||||
return 51
|
||||
if cloudiness >= 0.8:
|
||||
return 3
|
||||
if cloudiness >= 0.62:
|
||||
return 2
|
||||
if cloudiness >= 0.48 or wind_speed >= 28:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def _layered_noise(self, latitude: float, longitude: float, key: str) -> float:
|
||||
regional = self._smooth_noise(latitude, longitude, f"{key}:regional", scale=2.4)
|
||||
local = self._smooth_noise(latitude, longitude, f"{key}:local", scale=0.45)
|
||||
micro = self._smooth_noise(latitude, longitude, f"{key}:micro", scale=0.12)
|
||||
return _clamp((regional * 0.58) + (local * 0.27) + (micro * 0.15), 0.0, 1.0)
|
||||
|
||||
def _smooth_noise(self, latitude: float, longitude: float, key: str, scale: float) -> float:
|
||||
grid_x = longitude / scale
|
||||
grid_y = latitude / scale
|
||||
x0 = math.floor(grid_x)
|
||||
y0 = math.floor(grid_y)
|
||||
tx = grid_x - x0
|
||||
ty = grid_y - y0
|
||||
|
||||
v00 = self._cell_noise(key, x0, y0)
|
||||
v10 = self._cell_noise(key, x0 + 1, y0)
|
||||
v01 = self._cell_noise(key, x0, y0 + 1)
|
||||
v11 = self._cell_noise(key, x0 + 1, y0 + 1)
|
||||
|
||||
tx = tx * tx * (3.0 - (2.0 * tx))
|
||||
ty = ty * ty * (3.0 - (2.0 * ty))
|
||||
top = (v00 * (1 - tx)) + (v10 * tx)
|
||||
bottom = (v01 * (1 - tx)) + (v11 * tx)
|
||||
return (top * (1 - ty)) + (bottom * ty)
|
||||
|
||||
def _cell_noise(self, key: str, grid_x: int, grid_y: int) -> float:
|
||||
seed_input = f"{self.seed_namespace}:{key}:{grid_x}:{grid_y}"
|
||||
digest = hashlib.sha256(seed_input.encode("ascii")).digest()
|
||||
seed = int.from_bytes(digest[:8], "big", signed=False)
|
||||
return random.Random(seed).random()
|
||||
|
||||
|
||||
def get_weather_adapter() -> BaseWeatherAdapter:
|
||||
from django.conf import settings
|
||||
|
||||
provider = getattr(settings, "WEATHER_DATA_PROVIDER", "open-meteo")
|
||||
if provider == "open-meteo":
|
||||
return OpenMeteoWeatherAdapter(
|
||||
base_url=settings.WEATHER_API_BASE_URL,
|
||||
api_key=settings.WEATHER_API_KEY,
|
||||
timeout=getattr(settings, "WEATHER_TIMEOUT_SECONDS", 60),
|
||||
)
|
||||
if provider == "mock":
|
||||
if not (getattr(settings, "DEBUG", False) or getattr(settings, "DEVELOP", False)):
|
||||
raise RuntimeError("Mock weather provider is disabled outside dev/test environments.")
|
||||
return MockWeatherAdapter(
|
||||
delay_seconds=getattr(settings, "WEATHER_MOCK_DELAY_SECONDS", 0.8)
|
||||
)
|
||||
raise ValueError(f"Unsupported weather data provider: {provider}")
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import WeatherForecast, WeatherParameter
|
||||
|
||||
|
||||
@admin.register(WeatherParameter)
|
||||
class WeatherParameterAdmin(admin.ModelAdmin):
|
||||
list_display = ("code", "name_fa", "unit", "created_at")
|
||||
search_fields = ("code", "name_fa")
|
||||
|
||||
|
||||
@admin.register(WeatherForecast)
|
||||
class WeatherForecastAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"location",
|
||||
"forecast_date",
|
||||
"temperature_min",
|
||||
"temperature_max",
|
||||
"precipitation",
|
||||
"et0",
|
||||
"fetched_at",
|
||||
)
|
||||
list_filter = ("forecast_date",)
|
||||
search_fields = ("location__latitude", "location__longitude")
|
||||
@@ -0,0 +1,36 @@
|
||||
from functools import cached_property
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WeatherConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "weather"
|
||||
verbose_name = "Weather Forecast"
|
||||
|
||||
@cached_property
|
||||
def farm_weather_service(self):
|
||||
from .farm_weather import FarmWeatherService
|
||||
|
||||
return FarmWeatherService()
|
||||
|
||||
def get_farm_weather_service(self):
|
||||
return self.farm_weather_service
|
||||
|
||||
@cached_property
|
||||
def water_need_service(self):
|
||||
from .water_need_prediction import WaterNeedPredictionService
|
||||
|
||||
return WaterNeedPredictionService()
|
||||
|
||||
def get_water_need_service(self):
|
||||
return self.water_need_service
|
||||
|
||||
@cached_property
|
||||
def weather_data_adapter(self):
|
||||
from .adapters import get_weather_adapter
|
||||
|
||||
return get_weather_adapter()
|
||||
|
||||
def get_weather_data_adapter(self):
|
||||
return self.weather_data_adapter
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from farm_data.models import SensorData
|
||||
|
||||
from .services import get_forecast_for_location
|
||||
|
||||
|
||||
WMO_CONDITIONS = {
|
||||
0: "صاف",
|
||||
1: "عمدتاً صاف",
|
||||
2: "نیمهابری",
|
||||
3: "ابری",
|
||||
45: "مه",
|
||||
48: "مه یخزده",
|
||||
51: "نمنم باران",
|
||||
61: "بارش خفیف",
|
||||
63: "بارش متوسط",
|
||||
65: "بارش شدید",
|
||||
71: "برف خفیف",
|
||||
80: "رگبار",
|
||||
95: "رعد و برق",
|
||||
}
|
||||
|
||||
|
||||
def _safe_number(value, default=0):
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def _average(values, default=0):
|
||||
clean_values = [value for value in values if value is not None]
|
||||
if not clean_values:
|
||||
return default
|
||||
return sum(clean_values) / len(clean_values)
|
||||
|
||||
|
||||
def _weather_condition(weather_code):
|
||||
return WMO_CONDITIONS.get(weather_code, "نامشخص")
|
||||
|
||||
|
||||
def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]:
|
||||
if not forecasts:
|
||||
return {
|
||||
"condition": "نامشخص",
|
||||
"temperature": 0,
|
||||
"unit": "°C",
|
||||
"humidity": 0,
|
||||
"windSpeed": 0,
|
||||
"windUnit": "km/h",
|
||||
"chartData": {"labels": [], "series": [[]]},
|
||||
}
|
||||
|
||||
current_forecast = forecasts[0]
|
||||
labels = [str(forecast.forecast_date) for forecast in forecasts[:7]]
|
||||
series = [[round(_safe_number(forecast.temperature_mean, 0)) for forecast in forecasts[:7]]]
|
||||
|
||||
return {
|
||||
"condition": _weather_condition(current_forecast.weather_code),
|
||||
"temperature": round(_safe_number(current_forecast.temperature_mean, current_forecast.temperature_max)),
|
||||
"unit": "°C",
|
||||
"humidity": round(_average([current_forecast.humidity_mean], default=0)),
|
||||
"windSpeed": round(_safe_number(current_forecast.wind_speed_max, 0)),
|
||||
"windUnit": "km/h",
|
||||
"chartData": {
|
||||
"labels": labels,
|
||||
"series": series,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FarmWeatherService:
|
||||
def get_farm_weather_card(self, *, farm_uuid: str) -> dict[str, Any]:
|
||||
sensor = (
|
||||
SensorData.objects.select_related("center_location")
|
||||
.filter(farm_uuid=farm_uuid)
|
||||
.first()
|
||||
)
|
||||
if sensor is None:
|
||||
raise ValueError("Farm not found.")
|
||||
|
||||
forecasts = get_forecast_for_location(sensor.center_location, days=7)
|
||||
return _build_farm_weather_card(forecasts)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Management command to seed fixed weather forecasts for the demo farm location.
|
||||
Run: python manage.py seed_weather_data
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from location_data.models import SoilLocation
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
|
||||
DEMO_LATITUDE = "50.000000"
|
||||
DEMO_LONGITUDE = "50.000000"
|
||||
DEMO_FORECASTS = [
|
||||
{
|
||||
"day_offset": 0,
|
||||
"temperature_min": 14.0,
|
||||
"temperature_max": 24.5,
|
||||
"temperature_mean": 19.3,
|
||||
"precipitation": 0.0,
|
||||
"precipitation_probability": 5.0,
|
||||
"humidity_mean": 48.0,
|
||||
"wind_speed_max": 12.0,
|
||||
"et0": 4.2,
|
||||
"weather_code": 1,
|
||||
},
|
||||
{
|
||||
"day_offset": 1,
|
||||
"temperature_min": 13.5,
|
||||
"temperature_max": 22.0,
|
||||
"temperature_mean": 17.8,
|
||||
"precipitation": 2.4,
|
||||
"precipitation_probability": 60.0,
|
||||
"humidity_mean": 61.0,
|
||||
"wind_speed_max": 18.0,
|
||||
"et0": 3.7,
|
||||
"weather_code": 61,
|
||||
},
|
||||
{
|
||||
"day_offset": 2,
|
||||
"temperature_min": 12.8,
|
||||
"temperature_max": 20.5,
|
||||
"temperature_mean": 16.4,
|
||||
"precipitation": 4.8,
|
||||
"precipitation_probability": 78.0,
|
||||
"humidity_mean": 68.0,
|
||||
"wind_speed_max": 20.0,
|
||||
"et0": 3.1,
|
||||
"weather_code": 63,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed weather forecast rows for the fixed 50.00, 50.00 demo location."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
location, _ = SoilLocation.objects.get_or_create(
|
||||
latitude=DEMO_LATITUDE,
|
||||
longitude=DEMO_LONGITUDE,
|
||||
)
|
||||
today = timezone.now().date()
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Using SoilLocation id={location.id} at ({location.latitude}, {location.longitude})"
|
||||
)
|
||||
)
|
||||
|
||||
for item in DEMO_FORECASTS:
|
||||
forecast_date = today + timedelta(days=item["day_offset"])
|
||||
defaults = {
|
||||
key: value
|
||||
for key, value in item.items()
|
||||
if key != "day_offset"
|
||||
}
|
||||
_, created = WeatherForecast.objects.update_or_create(
|
||||
location=location,
|
||||
forecast_date=forecast_date,
|
||||
defaults=defaults,
|
||||
)
|
||||
status_text = "Created" if created else "Updated"
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" {status_text} WeatherForecast for {forecast_date}")
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("\nDone seeding weather_data demo records."))
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Management command to seed weather parameters.
|
||||
Run: python manage.py seed_weather_parameters
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from weather.models import WeatherParameter
|
||||
|
||||
|
||||
INITIAL_PARAMETERS = [
|
||||
("temperature_min", "حداقل دمای هوا", "°C"),
|
||||
("temperature_max", "حداکثر دمای هوا", "°C"),
|
||||
("temperature_mean", "میانگین دمای هوا", "°C"),
|
||||
("precipitation", "مجموع بارش", "mm"),
|
||||
("precipitation_probability", "احتمال بارش", "%"),
|
||||
("humidity_mean", "میانگین رطوبت نسبی", "%"),
|
||||
("wind_speed_max", "حداکثر سرعت باد", "km/h"),
|
||||
("et0", "تبخیر-تعرق مرجع (ET₀)", "mm/day"),
|
||||
("weather_code", "کد وضعیت آبوهوا (WMO)", ""),
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed weather parameters (temperature, precipitation, ET0, etc.)"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
created_count = 0
|
||||
for code, name_fa, unit in INITIAL_PARAMETERS:
|
||||
_, created = WeatherParameter.objects.get_or_create(
|
||||
code=code,
|
||||
defaults={"name_fa": name_fa, "unit": unit},
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" Created: {code} ({name_fa})")
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"\nDone. Created {created_count} new weather parameters."
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
# Generated manually for weather
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("location_data", "0002_soildepthdata_refactor"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# ── WeatherParameter ──
|
||||
migrations.CreateModel(
|
||||
name="WeatherParameter",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"code",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
help_text="کد یکتا (مثلاً temperature_max)",
|
||||
max_length=64,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"name_fa",
|
||||
models.CharField(
|
||||
help_text="نام فارسی",
|
||||
max_length=128,
|
||||
),
|
||||
),
|
||||
(
|
||||
"unit",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="واحد اندازهگیری",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["code"],
|
||||
"verbose_name": "پارامتر هواشناسی",
|
||||
"verbose_name_plural": "پارامترهای هواشناسی",
|
||||
},
|
||||
),
|
||||
# ── WeatherForecast ──
|
||||
migrations.CreateModel(
|
||||
name="WeatherForecast",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"location",
|
||||
models.ForeignKey(
|
||||
help_text="موقعیت مکانی مرتبط از جدول SoilLocation",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="weather_forecasts",
|
||||
to="location_data.soillocation",
|
||||
),
|
||||
),
|
||||
(
|
||||
"forecast_date",
|
||||
models.DateField(
|
||||
db_index=True,
|
||||
help_text="تاریخ پیشبینی",
|
||||
),
|
||||
),
|
||||
(
|
||||
"temperature_min",
|
||||
models.FloatField(
|
||||
blank=True,
|
||||
help_text="حداقل دمای هوا (°C)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"temperature_max",
|
||||
models.FloatField(
|
||||
blank=True,
|
||||
help_text="حداکثر دمای هوا (°C)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"temperature_mean",
|
||||
models.FloatField(
|
||||
blank=True,
|
||||
help_text="میانگین دمای هوا (°C)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"precipitation",
|
||||
models.FloatField(
|
||||
blank=True,
|
||||
help_text="مجموع بارش (mm)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"precipitation_probability",
|
||||
models.FloatField(
|
||||
blank=True,
|
||||
help_text="احتمال بارش (%)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"humidity_mean",
|
||||
models.FloatField(
|
||||
blank=True,
|
||||
help_text="میانگین رطوبت نسبی (%)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wind_speed_max",
|
||||
models.FloatField(
|
||||
blank=True,
|
||||
help_text="حداکثر سرعت باد (km/h)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"et0",
|
||||
models.FloatField(
|
||||
blank=True,
|
||||
help_text="تبخیر-تعرق مرجع (ET₀) — mm/day",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"weather_code",
|
||||
models.IntegerField(
|
||||
blank=True,
|
||||
help_text="کد وضعیت آبوهوا (WMO code)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"fetched_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="آخرین زمان واکشی از API",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["location", "forecast_date"],
|
||||
"verbose_name": "پیشبینی هواشناسی",
|
||||
"verbose_name_plural": "پیشبینیهای هواشناسی",
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="weatherforecast",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("location", "forecast_date"),
|
||||
name="weather_unique_location_date",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
# Seed migration: populate initial weather parameters.
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
INITIAL_PARAMETERS = [
|
||||
("temperature_min", "حداقل دمای هوا", "°C"),
|
||||
("temperature_max", "حداکثر دمای هوا", "°C"),
|
||||
("temperature_mean", "میانگین دمای هوا", "°C"),
|
||||
("precipitation", "مجموع بارش", "mm"),
|
||||
("precipitation_probability", "احتمال بارش", "%"),
|
||||
("humidity_mean", "میانگین رطوبت نسبی", "%"),
|
||||
("wind_speed_max", "حداکثر سرعت باد", "km/h"),
|
||||
("et0", "تبخیر-تعرق مرجع (ET₀)", "mm/day"),
|
||||
("weather_code", "کد وضعیت آبوهوا (WMO)", ""),
|
||||
]
|
||||
|
||||
|
||||
def seed_parameters(apps, schema_editor):
|
||||
WeatherParameter = apps.get_model("weather", "WeatherParameter")
|
||||
for code, name_fa, unit in INITIAL_PARAMETERS:
|
||||
WeatherParameter.objects.get_or_create(
|
||||
code=code,
|
||||
defaults={"name_fa": name_fa, "unit": unit},
|
||||
)
|
||||
|
||||
|
||||
def unseed_parameters(apps, schema_editor):
|
||||
WeatherParameter = apps.get_model("weather", "WeatherParameter")
|
||||
codes = [p[0] for p in INITIAL_PARAMETERS]
|
||||
WeatherParameter.objects.filter(code__in=codes).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("weather", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_parameters, unseed_parameters),
|
||||
]
|
||||
@@ -0,0 +1,137 @@
|
||||
# Seed migration: populate sample 7-day weather forecasts for existing SoilLocations.
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
SAMPLE_DAILY_DATA = [
|
||||
{
|
||||
"day_offset": 0,
|
||||
"temperature_min": 18.5,
|
||||
"temperature_max": 33.2,
|
||||
"temperature_mean": 25.8,
|
||||
"precipitation": 0.0,
|
||||
"precipitation_probability": 5.0,
|
||||
"humidity_mean": 28.0,
|
||||
"wind_speed_max": 12.0,
|
||||
"et0": 6.8,
|
||||
"weather_code": 0,
|
||||
},
|
||||
{
|
||||
"day_offset": 1,
|
||||
"temperature_min": 19.0,
|
||||
"temperature_max": 34.5,
|
||||
"temperature_mean": 26.7,
|
||||
"precipitation": 0.0,
|
||||
"precipitation_probability": 10.0,
|
||||
"humidity_mean": 30.0,
|
||||
"wind_speed_max": 14.0,
|
||||
"et0": 7.1,
|
||||
"weather_code": 1,
|
||||
},
|
||||
{
|
||||
"day_offset": 2,
|
||||
"temperature_min": 20.2,
|
||||
"temperature_max": 32.0,
|
||||
"temperature_mean": 26.1,
|
||||
"precipitation": 3.5,
|
||||
"precipitation_probability": 65.0,
|
||||
"humidity_mean": 52.0,
|
||||
"wind_speed_max": 18.0,
|
||||
"et0": 5.2,
|
||||
"weather_code": 61,
|
||||
},
|
||||
{
|
||||
"day_offset": 3,
|
||||
"temperature_min": 17.8,
|
||||
"temperature_max": 28.5,
|
||||
"temperature_mean": 23.1,
|
||||
"precipitation": 12.0,
|
||||
"precipitation_probability": 85.0,
|
||||
"humidity_mean": 70.0,
|
||||
"wind_speed_max": 22.0,
|
||||
"et0": 3.8,
|
||||
"weather_code": 63,
|
||||
},
|
||||
{
|
||||
"day_offset": 4,
|
||||
"temperature_min": 16.5,
|
||||
"temperature_max": 27.0,
|
||||
"temperature_mean": 21.7,
|
||||
"precipitation": 5.0,
|
||||
"precipitation_probability": 55.0,
|
||||
"humidity_mean": 60.0,
|
||||
"wind_speed_max": 16.0,
|
||||
"et0": 4.5,
|
||||
"weather_code": 61,
|
||||
},
|
||||
{
|
||||
"day_offset": 5,
|
||||
"temperature_min": 18.0,
|
||||
"temperature_max": 31.0,
|
||||
"temperature_mean": 24.5,
|
||||
"precipitation": 0.0,
|
||||
"precipitation_probability": 8.0,
|
||||
"humidity_mean": 35.0,
|
||||
"wind_speed_max": 10.0,
|
||||
"et0": 6.2,
|
||||
"weather_code": 2,
|
||||
},
|
||||
{
|
||||
"day_offset": 6,
|
||||
"temperature_min": 19.5,
|
||||
"temperature_max": 34.0,
|
||||
"temperature_mean": 26.7,
|
||||
"precipitation": 0.0,
|
||||
"precipitation_probability": 3.0,
|
||||
"humidity_mean": 25.0,
|
||||
"wind_speed_max": 8.0,
|
||||
"et0": 7.0,
|
||||
"weather_code": 0,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def seed_forecasts(apps, schema_editor):
|
||||
SoilLocation = apps.get_model("location_data", "SoilLocation")
|
||||
WeatherForecast = apps.get_model("weather", "WeatherForecast")
|
||||
|
||||
today = timezone.now().date()
|
||||
|
||||
for location in SoilLocation.objects.all():
|
||||
for daily in SAMPLE_DAILY_DATA:
|
||||
forecast_date = today + timedelta(days=daily["day_offset"])
|
||||
WeatherForecast.objects.get_or_create(
|
||||
location=location,
|
||||
forecast_date=forecast_date,
|
||||
defaults={
|
||||
"temperature_min": daily["temperature_min"],
|
||||
"temperature_max": daily["temperature_max"],
|
||||
"temperature_mean": daily["temperature_mean"],
|
||||
"precipitation": daily["precipitation"],
|
||||
"precipitation_probability": daily["precipitation_probability"],
|
||||
"humidity_mean": daily["humidity_mean"],
|
||||
"wind_speed_max": daily["wind_speed_max"],
|
||||
"et0": daily["et0"],
|
||||
"weather_code": daily["weather_code"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def unseed_forecasts(apps, schema_editor):
|
||||
WeatherForecast = apps.get_model("weather", "WeatherForecast")
|
||||
WeatherForecast.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("weather", "0002_seed_weather_parameters"),
|
||||
("location_data", "0002_soildepthdata_refactor"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_forecasts, unseed_forecasts),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class WeatherParameter(models.Model):
|
||||
"""
|
||||
تعریف پارامترهای هواشناسی (مثلاً دما، بارش، تبخیر-تعرق، ...).
|
||||
"""
|
||||
|
||||
code = models.CharField(
|
||||
max_length=64,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="کد یکتا (مثلاً temperature_max)",
|
||||
)
|
||||
name_fa = models.CharField(max_length=128, help_text="نام فارسی")
|
||||
unit = models.CharField(max_length=32, blank=True, help_text="واحد اندازهگیری")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["code"]
|
||||
verbose_name = "پارامتر هواشناسی"
|
||||
verbose_name_plural = "پارامترهای هواشناسی"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.code} ({self.name_fa})"
|
||||
|
||||
|
||||
class WeatherForecast(models.Model):
|
||||
"""
|
||||
پیشبینی هواشناسی روزانه (تا ۷ روز آینده) برای یک SoilLocation.
|
||||
دادهها شامل دما، بارش، رطوبت، باد و تبخیر-تعرق مرجع (ET0) هستند.
|
||||
"""
|
||||
|
||||
location = models.ForeignKey(
|
||||
"location_data.SoilLocation",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="weather_forecasts",
|
||||
help_text="موقعیت مکانی مرتبط از جدول SoilLocation",
|
||||
)
|
||||
forecast_date = models.DateField(
|
||||
db_index=True,
|
||||
help_text="تاریخ پیشبینی",
|
||||
)
|
||||
|
||||
temperature_min = models.FloatField(
|
||||
null=True, blank=True, help_text="حداقل دمای هوا (°C)"
|
||||
)
|
||||
temperature_max = models.FloatField(
|
||||
null=True, blank=True, help_text="حداکثر دمای هوا (°C)"
|
||||
)
|
||||
temperature_mean = models.FloatField(
|
||||
null=True, blank=True, help_text="میانگین دمای هوا (°C)"
|
||||
)
|
||||
|
||||
precipitation = models.FloatField(
|
||||
null=True, blank=True, help_text="مجموع بارش (mm)"
|
||||
)
|
||||
precipitation_probability = models.FloatField(
|
||||
null=True, blank=True, help_text="احتمال بارش (%)"
|
||||
)
|
||||
|
||||
humidity_mean = models.FloatField(
|
||||
null=True, blank=True, help_text="میانگین رطوبت نسبی (%)"
|
||||
)
|
||||
|
||||
wind_speed_max = models.FloatField(
|
||||
null=True, blank=True, help_text="حداکثر سرعت باد (km/h)"
|
||||
)
|
||||
|
||||
et0 = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="تبخیر-تعرق مرجع (ET₀) — mm/day",
|
||||
)
|
||||
|
||||
weather_code = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="کد وضعیت آبوهوا (WMO code)",
|
||||
)
|
||||
|
||||
fetched_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="آخرین زمان واکشی از API",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["location", "forecast_date"],
|
||||
name="weather_unique_location_date",
|
||||
)
|
||||
]
|
||||
ordering = ["location", "forecast_date"]
|
||||
verbose_name = "پیشبینی هواشناسی"
|
||||
verbose_name_plural = "پیشبینیهای هواشناسی"
|
||||
|
||||
def __str__(self):
|
||||
return f"WeatherForecast({self.location_id}, {self.forecast_date})"
|
||||
|
||||
@property
|
||||
def will_rain(self):
|
||||
"""آیا بارندگی پیشبینی شده است؟"""
|
||||
if self.precipitation is not None:
|
||||
return self.precipitation > 0.0
|
||||
return None
|
||||
@@ -0,0 +1,35 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class FarmWeatherRequestSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه")
|
||||
|
||||
|
||||
class WeatherChartDataSerializer(serializers.Serializer):
|
||||
labels = serializers.ListField(child=serializers.CharField())
|
||||
series = serializers.ListField(child=serializers.ListField(child=serializers.FloatField()))
|
||||
|
||||
|
||||
class FarmWeatherResponseSerializer(serializers.Serializer):
|
||||
condition = serializers.CharField()
|
||||
temperature = serializers.FloatField()
|
||||
unit = serializers.CharField()
|
||||
humidity = serializers.FloatField()
|
||||
windSpeed = serializers.FloatField()
|
||||
windUnit = serializers.CharField()
|
||||
chartData = WeatherChartDataSerializer()
|
||||
|
||||
|
||||
class WaterNeedPredictionRequestSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه")
|
||||
|
||||
|
||||
class WaterNeedPredictionResponseSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.CharField()
|
||||
totalNext7Days = serializers.FloatField()
|
||||
unit = serializers.CharField()
|
||||
categories = serializers.ListField(child=serializers.CharField())
|
||||
series = serializers.JSONField()
|
||||
dailyBreakdown = serializers.JSONField()
|
||||
insight = serializers.JSONField()
|
||||
raw_response = serializers.CharField(allow_null=True, required=False)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
سرویسهای هواشناسی — واکشی پیشبینی ۷ روزه و ذخیره در دیتابیس.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import transaction
|
||||
|
||||
from location_data.models import SoilLocation
|
||||
|
||||
from .adapters import DEFAULT_FORECAST_DAYS
|
||||
from .models import WeatherForecast
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fetch_weather_from_api(latitude: float, longitude: float) -> dict | None:
|
||||
"""
|
||||
واکشی پیشبینی هواشناسی از provider فعال.
|
||||
خروجی در قالب سازگار با Open-Meteo daily format برگردانده میشود.
|
||||
"""
|
||||
adapter = apps.get_app_config("weather").get_weather_data_adapter()
|
||||
return adapter.fetch_forecast(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
days=DEFAULT_FORECAST_DAYS,
|
||||
)
|
||||
|
||||
|
||||
def parse_weather_response(data: dict) -> list[dict]:
|
||||
"""
|
||||
تبدیل پاسخ API به لیست dict برای ذخیره در WeatherForecast.
|
||||
فرمت ورودی: Open-Meteo daily format.
|
||||
"""
|
||||
daily = data.get("daily", {})
|
||||
times = daily.get("time", [])
|
||||
forecasts = []
|
||||
|
||||
for index, date_str in enumerate(times):
|
||||
forecasts.append(
|
||||
{
|
||||
"forecast_date": date_str,
|
||||
"temperature_max": _safe_index(daily.get("temperature_2m_max"), index),
|
||||
"temperature_min": _safe_index(daily.get("temperature_2m_min"), index),
|
||||
"temperature_mean": _safe_index(daily.get("temperature_2m_mean"), index),
|
||||
"precipitation": _safe_index(daily.get("precipitation_sum"), index),
|
||||
"precipitation_probability": _safe_index(
|
||||
daily.get("precipitation_probability_max"),
|
||||
index,
|
||||
),
|
||||
"humidity_mean": _safe_index(
|
||||
daily.get("relative_humidity_2m_mean"),
|
||||
index,
|
||||
),
|
||||
"wind_speed_max": _safe_index(daily.get("wind_speed_10m_max"), index),
|
||||
"et0": _safe_index(
|
||||
daily.get("et0_fao_evapotranspiration"),
|
||||
index,
|
||||
),
|
||||
"weather_code": _safe_index(daily.get("weather_code"), index),
|
||||
}
|
||||
)
|
||||
return forecasts
|
||||
|
||||
|
||||
def _safe_index(lst: list | None, index: int):
|
||||
"""مقدار index را از لیست برمیگرداند یا None."""
|
||||
if lst is None or index >= len(lst):
|
||||
return None
|
||||
return lst[index]
|
||||
|
||||
|
||||
def update_weather_for_location(location: SoilLocation) -> dict:
|
||||
"""
|
||||
واکشی و ذخیره پیشبینی هواشناسی ۷ روزه برای یک SoilLocation.
|
||||
|
||||
خروجی:
|
||||
{"status": "success"|"no_data"|"error", "location_id": int, ...}
|
||||
"""
|
||||
lat = float(location.latitude)
|
||||
lon = float(location.longitude)
|
||||
|
||||
try:
|
||||
data = fetch_weather_from_api(lat, lon)
|
||||
except Exception as exc:
|
||||
logger.error("Weather API error for location %s: %s", location.id, exc)
|
||||
return {
|
||||
"status": "error",
|
||||
"location_id": location.id,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
if data is None:
|
||||
logger.info("Weather provider returned no data for location %s.", location.id)
|
||||
return {
|
||||
"status": "no_data",
|
||||
"location_id": location.id,
|
||||
"message": "Weather provider returned no data.",
|
||||
}
|
||||
|
||||
forecasts = parse_weather_response(data)
|
||||
|
||||
with transaction.atomic():
|
||||
for forecast in forecasts:
|
||||
WeatherForecast.objects.update_or_create(
|
||||
location=location,
|
||||
forecast_date=forecast.pop("forecast_date"),
|
||||
defaults=forecast,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"location_id": location.id,
|
||||
"days_updated": len(forecasts),
|
||||
}
|
||||
|
||||
|
||||
def update_weather_for_all_locations() -> list[dict]:
|
||||
"""
|
||||
واکشی پیشبینی هواشناسی برای تمام SoilLocationهای موجود.
|
||||
"""
|
||||
results = []
|
||||
for location in SoilLocation.objects.all():
|
||||
results.append(update_weather_for_location(location))
|
||||
return results
|
||||
|
||||
|
||||
def get_forecast_for_location(
|
||||
location: SoilLocation,
|
||||
days: int = DEFAULT_FORECAST_DAYS,
|
||||
) -> list[WeatherForecast]:
|
||||
"""
|
||||
دریافت پیشبینیهای ذخیرهشده برای یک location (تا N روز آینده).
|
||||
"""
|
||||
today = date.today()
|
||||
end_date = today + timedelta(days=days)
|
||||
return list(
|
||||
WeatherForecast.objects.filter(
|
||||
location=location,
|
||||
forecast_date__gte=today,
|
||||
forecast_date__lte=end_date,
|
||||
).order_by("forecast_date")
|
||||
)
|
||||
|
||||
|
||||
def should_irrigate_today(location: SoilLocation) -> dict:
|
||||
"""
|
||||
بررسی ساده: آیا فردا باران میبارد؟
|
||||
اگر بارش فردا بیشتر از آستانه باشد → آبیاری لازم نیست.
|
||||
"""
|
||||
tomorrow = date.today() + timedelta(days=1)
|
||||
forecast = WeatherForecast.objects.filter(
|
||||
location=location,
|
||||
forecast_date=tomorrow,
|
||||
).first()
|
||||
|
||||
if forecast is None:
|
||||
return {
|
||||
"needs_irrigation": None,
|
||||
"tomorrow_precipitation": None,
|
||||
"tomorrow_date": str(tomorrow),
|
||||
"reason": "داده پیشبینی فردا موجود نیست.",
|
||||
}
|
||||
|
||||
rain_threshold_mm = 2.0
|
||||
if forecast.precipitation is not None and forecast.precipitation >= rain_threshold_mm:
|
||||
return {
|
||||
"needs_irrigation": False,
|
||||
"tomorrow_precipitation": forecast.precipitation,
|
||||
"tomorrow_date": str(tomorrow),
|
||||
"reason": (
|
||||
f"فردا {forecast.precipitation} mm بارش پیشبینی شده — "
|
||||
"نیاز به آبیاری نیست."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"needs_irrigation": True,
|
||||
"tomorrow_precipitation": forecast.precipitation,
|
||||
"tomorrow_date": str(tomorrow),
|
||||
"reason": "بارش فردا ناچیز یا صفر — آبیاری توصیه میشود.",
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
تسکهای Celery برای واکشی دادههای هواشناسی.
|
||||
"""
|
||||
|
||||
from config.celery import app
|
||||
|
||||
from location_data.models import SoilLocation
|
||||
|
||||
from .services import update_weather_for_location, update_weather_for_all_locations
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def fetch_weather_task(self, location_id: int):
|
||||
"""
|
||||
واکشی پیشبینی هواشناسی ۷ روزه برای یک location مشخص.
|
||||
"""
|
||||
try:
|
||||
location = SoilLocation.objects.get(pk=location_id)
|
||||
except SoilLocation.DoesNotExist:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"SoilLocation with id={location_id} not found.",
|
||||
}
|
||||
|
||||
return update_weather_for_location(location)
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def fetch_weather_all_locations_task(self):
|
||||
"""
|
||||
واکشی پیشبینی هواشناسی برای تمام locationها.
|
||||
مناسب برای Celery Beat (مثلاً هر ۶ ساعت).
|
||||
"""
|
||||
return update_weather_for_all_locations()
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.apps import apps
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
|
||||
from location_data.models import SoilLocation
|
||||
from weather.adapters import MockWeatherAdapter, OpenMeteoWeatherAdapter
|
||||
from weather.models import WeatherForecast
|
||||
from weather.services import fetch_weather_from_api, update_weather_for_location
|
||||
|
||||
|
||||
class MockWeatherAdapterTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.adapter = MockWeatherAdapter(delay_seconds=0)
|
||||
|
||||
def test_same_coordinate_returns_same_forecast(self):
|
||||
first = self.adapter.fetch_forecast(35.71, 51.4)
|
||||
second = self.adapter.fetch_forecast(35.71, 51.4)
|
||||
|
||||
self.assertEqual(first, second)
|
||||
|
||||
def test_nearby_coordinates_produce_nearby_forecast(self):
|
||||
first = self.adapter.fetch_forecast(35.71, 51.4)
|
||||
second = self.adapter.fetch_forecast(35.715, 51.405)
|
||||
|
||||
first_daily = first["daily"]
|
||||
second_daily = second["daily"]
|
||||
self.assertLess(
|
||||
abs(first_daily["temperature_2m_mean"][0] - second_daily["temperature_2m_mean"][0]),
|
||||
2.5,
|
||||
)
|
||||
self.assertLess(
|
||||
abs(first_daily["relative_humidity_2m_mean"][0] - second_daily["relative_humidity_2m_mean"][0]),
|
||||
8.0,
|
||||
)
|
||||
self.assertLess(
|
||||
abs(first_daily["wind_speed_10m_max"][0] - second_daily["wind_speed_10m_max"][0]),
|
||||
6.0,
|
||||
)
|
||||
|
||||
def test_shape_matches_open_meteo_daily_contract(self):
|
||||
forecast = self.adapter.fetch_forecast(35.71, 51.4)
|
||||
daily = forecast["daily"]
|
||||
|
||||
self.assertEqual(len(daily["time"]), 7)
|
||||
self.assertEqual(len(daily["temperature_2m_max"]), 7)
|
||||
self.assertEqual(len(daily["weather_code"]), 7)
|
||||
|
||||
|
||||
class WeatherAdapterSelectionTests(SimpleTestCase):
|
||||
def tearDown(self):
|
||||
apps.get_app_config("weather").__dict__.pop("weather_data_adapter", None)
|
||||
|
||||
@override_settings(WEATHER_DATA_PROVIDER="mock", WEATHER_MOCK_DELAY_SECONDS=0)
|
||||
def test_app_config_returns_mock_adapter(self):
|
||||
config = apps.get_app_config("weather")
|
||||
config.__dict__.pop("weather_data_adapter", None)
|
||||
|
||||
adapter = config.get_weather_data_adapter()
|
||||
|
||||
self.assertIsInstance(adapter, MockWeatherAdapter)
|
||||
|
||||
@override_settings(WEATHER_DATA_PROVIDER="open-meteo", WEATHER_TIMEOUT_SECONDS=12)
|
||||
def test_app_config_returns_live_adapter(self):
|
||||
config = apps.get_app_config("weather")
|
||||
config.__dict__.pop("weather_data_adapter", None)
|
||||
|
||||
adapter = config.get_weather_data_adapter()
|
||||
|
||||
self.assertIsInstance(adapter, OpenMeteoWeatherAdapter)
|
||||
self.assertEqual(adapter.timeout, 12)
|
||||
|
||||
|
||||
@override_settings(WEATHER_DATA_PROVIDER="mock", WEATHER_MOCK_DELAY_SECONDS=0)
|
||||
class WeatherServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.location = SoilLocation.objects.create(
|
||||
latitude="35.710000",
|
||||
longitude="51.400000",
|
||||
)
|
||||
|
||||
def test_fetch_weather_from_api_uses_mock_provider(self):
|
||||
payload = fetch_weather_from_api(35.71, 51.4)
|
||||
|
||||
self.assertIn("daily", payload)
|
||||
self.assertEqual(len(payload["daily"]["time"]), 7)
|
||||
|
||||
def test_update_weather_for_location_persists_seven_days(self):
|
||||
result = update_weather_for_location(self.location)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["days_updated"], 7)
|
||||
self.assertEqual(
|
||||
WeatherForecast.objects.filter(location=self.location).count(),
|
||||
7,
|
||||
)
|
||||
self.assertTrue(
|
||||
WeatherForecast.objects.filter(
|
||||
location=self.location,
|
||||
precipitation__isnull=False,
|
||||
weather_code__isnull=False,
|
||||
).exists()
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from rag.failure_contract import RAGServiceError
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="weather.urls")
|
||||
class FarmWeatherApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
@patch("weather.views.apps.get_app_config")
|
||||
def test_farm_weather_api_returns_payload(self, mock_get_app_config):
|
||||
mock_service = SimpleNamespace(
|
||||
get_farm_weather_card=lambda **_kwargs: {
|
||||
"condition": "صاف",
|
||||
"temperature": 28.0,
|
||||
"unit": "°C",
|
||||
"humidity": 42.0,
|
||||
"windSpeed": 15.0,
|
||||
"windUnit": "km/h",
|
||||
"chartData": {
|
||||
"labels": ["2026-04-01", "2026-04-02"],
|
||||
"series": [[28.0, 29.0]],
|
||||
},
|
||||
}
|
||||
)
|
||||
mock_get_app_config.return_value = SimpleNamespace(
|
||||
get_farm_weather_service=lambda: mock_service
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/farm-card/",
|
||||
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["condition"], "صاف")
|
||||
self.assertEqual(payload["chartData"]["labels"][0], "2026-04-01")
|
||||
|
||||
@patch("weather.views.apps.get_app_config")
|
||||
def test_farm_weather_api_returns_404_for_missing_farm(self, mock_get_app_config):
|
||||
mock_service = SimpleNamespace(
|
||||
get_farm_weather_card=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found."))
|
||||
)
|
||||
mock_get_app_config.return_value = SimpleNamespace(
|
||||
get_farm_weather_service=lambda: mock_service
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/farm-card/",
|
||||
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["msg"], "Farm not found.")
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="weather.urls")
|
||||
class WaterNeedPredictionApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
@patch("weather.views.apps.get_app_config")
|
||||
def test_water_need_api_returns_payload(self, mock_get_app_config):
|
||||
mock_service = SimpleNamespace(
|
||||
get_water_need_prediction=lambda **_kwargs: {
|
||||
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"totalNext7Days": 24.6,
|
||||
"unit": "mm",
|
||||
"categories": ["روز 1", "روز 2"],
|
||||
"series": [{"name": "نیاز آبی تعدیلشده", "data": [3.2, 4.1]}],
|
||||
"dailyBreakdown": [
|
||||
{"forecast_date": "2026-04-01", "gross_irrigation_mm": 3.2},
|
||||
{"forecast_date": "2026-04-02", "gross_irrigation_mm": 4.1},
|
||||
],
|
||||
"insight": {
|
||||
"summary": "جمع نياز آبي هفته آينده حدود 24.6 ميلي متر است.",
|
||||
"irrigation_outlook": "نياز آبي در حال افزايش است.",
|
||||
"recommended_action": "آبياري صبح زود تنظيم شود.",
|
||||
"risk_note": "در صورت بارش موثر برنامه بازبيني شود.",
|
||||
"confidence": 0.82,
|
||||
},
|
||||
"raw_response": "{\"summary\":\"ok\"}",
|
||||
}
|
||||
)
|
||||
mock_get_app_config.return_value = SimpleNamespace(
|
||||
get_water_need_service=lambda: mock_service
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/water-need-prediction/",
|
||||
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["data"]
|
||||
self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000")
|
||||
self.assertEqual(payload["insight"]["confidence"], 0.82)
|
||||
|
||||
@patch("weather.views.apps.get_app_config")
|
||||
def test_water_need_api_returns_404_for_missing_farm(self, mock_get_app_config):
|
||||
mock_service = SimpleNamespace(
|
||||
get_water_need_prediction=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found."))
|
||||
)
|
||||
mock_get_app_config.return_value = SimpleNamespace(
|
||||
get_water_need_service=lambda: mock_service
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/water-need-prediction/",
|
||||
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["msg"], "Farm not found.")
|
||||
|
||||
@patch("weather.views.apps.get_app_config")
|
||||
def test_water_need_api_returns_structured_failure_for_invalid_llm_json(self, mock_get_app_config):
|
||||
mock_service = SimpleNamespace(
|
||||
get_water_need_prediction=lambda **_kwargs: (_ for _ in ()).throw(
|
||||
RAGServiceError(
|
||||
error_code="invalid_json",
|
||||
message="Water need prediction LLM response was not valid JSON.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
http_status=502,
|
||||
)
|
||||
)
|
||||
)
|
||||
mock_get_app_config.return_value = SimpleNamespace(
|
||||
get_water_need_service=lambda: mock_service
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/water-need-prediction/",
|
||||
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertEqual(response.json()["data"]["error_code"], "invalid_json")
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import FarmWeatherCardView, WaterNeedPredictionView
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("farm-card/", FarmWeatherCardView.as_view(), name="farm-weather-card"),
|
||||
path("water-need-prediction/", WaterNeedPredictionView.as_view(), name="water-need-prediction"),
|
||||
]
|
||||
@@ -0,0 +1,151 @@
|
||||
from django.apps import apps
|
||||
|
||||
from drf_spectacular.utils import OpenApiExample, extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from config.openapi import build_envelope_serializer, build_response
|
||||
from rag.failure_contract import RAGServiceError
|
||||
|
||||
from .serializers import (
|
||||
FarmWeatherRequestSerializer,
|
||||
FarmWeatherResponseSerializer,
|
||||
WaterNeedPredictionRequestSerializer,
|
||||
WaterNeedPredictionResponseSerializer,
|
||||
)
|
||||
|
||||
|
||||
FarmWeatherEnvelopeSerializer = build_envelope_serializer(
|
||||
"FarmWeatherEnvelopeSerializer",
|
||||
FarmWeatherResponseSerializer,
|
||||
)
|
||||
WeatherErrorSerializer = build_envelope_serializer(
|
||||
"WeatherErrorSerializer",
|
||||
data_required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
WaterNeedPredictionEnvelopeSerializer = build_envelope_serializer(
|
||||
"WaterNeedPredictionEnvelopeSerializer",
|
||||
WaterNeedPredictionResponseSerializer,
|
||||
)
|
||||
|
||||
|
||||
class FarmWeatherCardView(APIView):
|
||||
@extend_schema(
|
||||
tags=["Weather"],
|
||||
summary="دریافت کارت آب و هوای مزرعه",
|
||||
description="با دریافت farm_uuid، داده مستقل کارت آب و هوای مزرعه را از اپ weather برمی گرداند.",
|
||||
request=FarmWeatherRequestSerializer,
|
||||
responses={
|
||||
200: build_response(
|
||||
FarmWeatherEnvelopeSerializer,
|
||||
"داده کارت آب و هوای مزرعه با موفقیت بازگردانده شد.",
|
||||
),
|
||||
400: build_response(
|
||||
WeatherErrorSerializer,
|
||||
"داده ورودی نامعتبر است.",
|
||||
),
|
||||
404: build_response(
|
||||
WeatherErrorSerializer,
|
||||
"مزرعه یافت نشد.",
|
||||
),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"نمونه درخواست weather",
|
||||
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
|
||||
request_only=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = FarmWeatherRequestSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
service = apps.get_app_config("weather").get_farm_weather_service()
|
||||
try:
|
||||
data = service.get_farm_weather_card(
|
||||
farm_uuid=str(serializer.validated_data["farm_uuid"])
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response(
|
||||
{"code": 404, "msg": str(exc), "data": None},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"code": 200, "msg": "success", "data": data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class WaterNeedPredictionView(APIView):
|
||||
@extend_schema(
|
||||
tags=["Weather"],
|
||||
summary="دریافت پیش بینی نیاز آبی کوتاه مدت مزرعه",
|
||||
description="با دریافت farm_uuid، محاسبات نیاز آبی 7 روز آینده را از اپ weather برمی گرداند و با RAG تفسیر عملیاتی اضافه می کند.",
|
||||
request=WaterNeedPredictionRequestSerializer,
|
||||
responses={
|
||||
200: build_response(
|
||||
WaterNeedPredictionEnvelopeSerializer,
|
||||
"داده پیش بینی نیاز آبی مزرعه با موفقیت بازگردانده شد.",
|
||||
),
|
||||
400: build_response(
|
||||
WeatherErrorSerializer,
|
||||
"داده ورودی نامعتبر است.",
|
||||
),
|
||||
404: build_response(
|
||||
WeatherErrorSerializer,
|
||||
"مزرعه یافت نشد.",
|
||||
),
|
||||
500: build_response(
|
||||
WeatherErrorSerializer,
|
||||
"خطا در تحلیل نیاز آبی مزرعه.",
|
||||
),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"نمونه درخواست water need",
|
||||
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
|
||||
request_only=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = WaterNeedPredictionRequestSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
service = apps.get_app_config("weather").get_water_need_service()
|
||||
try:
|
||||
data = service.get_water_need_prediction(
|
||||
farm_uuid=str(serializer.validated_data["farm_uuid"])
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response(
|
||||
{"code": 404, "msg": str(exc), "data": None},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except RAGServiceError as exc:
|
||||
return Response(
|
||||
{"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()},
|
||||
status=exc.http_status,
|
||||
)
|
||||
except Exception as exc:
|
||||
return Response(
|
||||
{"code": 500, "msg": f"خطا در تحلیل نیاز آبی مزرعه: {exc}", "data": None},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"code": 200, "msg": "success", "data": data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import clone_snapshot_as_runtime_plant, get_primary_plant_snapshot
|
||||
from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from rag.services import get_water_need_prediction_insight
|
||||
|
||||
from .services import get_forecast_for_location
|
||||
|
||||
|
||||
def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any]) -> dict[str, Any]:
|
||||
location = getattr(sensor, "center_location", None)
|
||||
plant = clone_snapshot_as_runtime_plant(get_primary_plant_snapshot(sensor))
|
||||
irrigation_method = getattr(sensor, "irrigation_method", None)
|
||||
|
||||
if not forecasts or location is None:
|
||||
return {
|
||||
"totalNext7Days": 0,
|
||||
"unit": "mm",
|
||||
"categories": [],
|
||||
"series": [],
|
||||
"dailyBreakdown": [],
|
||||
"cropProfile": {},
|
||||
"irrigationEfficiencyPercent": None,
|
||||
}
|
||||
|
||||
crop_profile = resolve_crop_profile(plant)
|
||||
efficiency = getattr(irrigation_method, "water_efficiency_percent", None) if irrigation_method else None
|
||||
daily = calculate_forecast_water_needs(
|
||||
forecasts=forecasts[:7],
|
||||
latitude_deg=float(location.latitude),
|
||||
crop_profile=crop_profile,
|
||||
growth_stage=crop_profile.get("current_stage"),
|
||||
irrigation_efficiency_percent=efficiency,
|
||||
)
|
||||
daily_requirements = [round(item["gross_irrigation_mm"], 2) for item in daily]
|
||||
|
||||
return {
|
||||
"totalNext7Days": round(sum(daily_requirements), 2),
|
||||
"unit": "mm",
|
||||
"categories": [f"روز {index}" for index in range(1, len(daily_requirements) + 1)],
|
||||
"series": [{"name": "نیاز آبی تعدیلشده", "data": daily_requirements}],
|
||||
"dailyBreakdown": daily,
|
||||
"cropProfile": crop_profile,
|
||||
"irrigationEfficiencyPercent": efficiency,
|
||||
}
|
||||
|
||||
|
||||
class WaterNeedPredictionService:
|
||||
def get_water_need_prediction(self, *, farm_uuid: str) -> dict[str, Any]:
|
||||
sensor = (
|
||||
SensorData.objects.select_related("center_location", "irrigation_method")
|
||||
.prefetch_related("plant_assignments__plant")
|
||||
.filter(farm_uuid=farm_uuid)
|
||||
.first()
|
||||
)
|
||||
if sensor is None:
|
||||
raise ValueError("Farm not found.")
|
||||
|
||||
forecasts = get_forecast_for_location(sensor.center_location, days=7)
|
||||
payload = build_water_need_prediction_payload(sensor=sensor, forecasts=forecasts)
|
||||
insight = get_water_need_prediction_insight(
|
||||
farm_uuid=farm_uuid,
|
||||
prediction_payload=payload,
|
||||
)
|
||||
|
||||
return {
|
||||
"farm_uuid": farm_uuid,
|
||||
**payload,
|
||||
"insight": {
|
||||
"summary": insight.get("summary"),
|
||||
"irrigation_outlook": insight.get("irrigation_outlook"),
|
||||
"recommended_action": insight.get("recommended_action"),
|
||||
"risk_note": insight.get("risk_note"),
|
||||
"confidence": insight.get("confidence"),
|
||||
},
|
||||
"raw_response": insight.get("raw_response"),
|
||||
}
|
||||
Reference in New Issue
Block a user