This commit is contained in:
2026-04-25 17:22:41 +03:30
parent 569d520a5c
commit aa24fc22b0
124 changed files with 8491 additions and 2582 deletions
+20
View File
@@ -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
+83
View File
@@ -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)
+37
View File
@@ -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)
+127
View File
@@ -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.")
+9
View File
@@ -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"),
]
+145
View File
@@ -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,
)
+83
View File
@@ -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"),
}