UPDATE
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
from functools import cached_property
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
@@ -5,3 +7,21 @@ 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
|
||||
|
||||
@@ -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,37 @@
|
||||
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()
|
||||
knowledge_base = serializers.CharField(allow_null=True, required=False)
|
||||
tone_file = serializers.CharField(allow_null=True, required=False)
|
||||
raw_response = serializers.CharField(allow_null=True, required=False)
|
||||
@@ -0,0 +1,127 @@
|
||||
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
|
||||
|
||||
|
||||
@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,
|
||||
},
|
||||
"knowledge_base": "water_need_prediction",
|
||||
"tone_file": "config/tones/water_need_prediction_tone.txt",
|
||||
"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["knowledge_base"], "water_need_prediction")
|
||||
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.")
|
||||
@@ -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,145 @@
|
||||
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 .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 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,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
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)
|
||||
plants = list(sensor.plants.all()) if hasattr(sensor, "plants") else []
|
||||
plant = plants[0] if plants else None
|
||||
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("plants")
|
||||
.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"),
|
||||
},
|
||||
"knowledge_base": insight.get("knowledge_base"),
|
||||
"tone_file": insight.get("tone_file"),
|
||||
"raw_response": insight.get("raw_response"),
|
||||
}
|
||||
Reference in New Issue
Block a user