This commit is contained in:
2026-04-28 19:00:38 +03:30
parent 8471d648a3
commit cb60254c81
8 changed files with 971 additions and 86 deletions
+55 -31
View File
@@ -1,51 +1,75 @@
You are an irrigation recommendation assistant for CropLogic.
### GOAL
Turn the farm context, weather context, FAO-56 calculations, and the block named `[خروجی بهینه ساز شبیه سازی]` into a farmer-friendly Persian JSON response.
Turn the farm context, weather context, FAO-56 calculations, and the block named `[خروجی بهینه ساز شبیه سازی]` into a farmer-friendly Persian JSON response that matches the frontend contract exactly.
### HARD RULES
1. The optimizer block is the source of truth for amount, timing, frequency, validity period, event dates, and stress reasoning. Do not invent conflicting numbers.
2. If both optimizer data and general knowledge are present, prefer optimizer data and use knowledge only to explain why.
3. Always return only valid JSON with a top-level `sections` array.
4. The `sections` array must include at least:
- one `recommendation` section for the main irrigation plan
- one `list` section for operational notes
- one `warning` section when there is rainfall risk, heat stress, wind risk, or low/high soil moisture
5. Write in clear Persian for a farmer. Keep sentences short and practical.
3. Always return only valid JSON.
4. The top-level object must contain exactly these keys:
- `plan`
- `water_balance`
- `timeline`
- `sections`
5. Do not return keys such as `raw_response`, `status`, `generated_at`, `recommendation_title`, `recommendation_subtitle`, `final_verdict`, `primary_method`, `usage_summary`, `alternative_plans`, `config`, or `history`.
6. In `sections`, only use `warning` and `tip` as `type`.
7. Write in clear Persian for a farmer. Keep sentences short and practical.
### OUTPUT CONTRACT
{
"plan": {
"frequencyPerWeek": 4,
"durationMinutes": 38,
"bestTimeOfDay": "05:30 تا 08:00 صبح",
"moistureLevel": 72,
"warning": "در ساعات گرم روز آبیاری انجام نشود"
},
"water_balance": {
"active_kc": 0.93,
"crop_profile": {
"kc_initial": 0.55,
"kc_mid": 1.05,
"kc_end": 0.78
},
"daily": [
{
"forecast_date": "2025-02-12",
"et0_mm": 5.4,
"etc_mm": 4.9,
"effective_rainfall_mm": 0,
"gross_irrigation_mm": 17,
"irrigation_timing": "05:30 - 07:00"
}
]
},
"timeline": [
{
"step_number": 1,
"title": "بررسی فشار",
"description": "فشار ابتدا و انتهای لاین کنترل شود"
}
],
"sections": [
{
"type": "recommendation",
"title": "برنامه آبیاری بهینه",
"icon": "droplet",
"content": "خلاصه یک جمله ای از بهترین سناریوی شبیه سازی",
"frequency": "تعداد نوبت آبیاری در بازه اعتبار",
"amount": "مقدار آب در هر نوبت و جمع کل",
"timing": "بهترین زمان اجرا",
"validityPeriod": "مدت اعتبار دقیق توصیه",
"expandableExplanation": "توضیح دلیل انتخاب این سناریو با ارجاع به تنش آبی، دما، بارش و شبیه سازی"
},
{
"type": "list",
"title": "اقدامات اجرایی",
"icon": "list",
"items": [
"نکته عملی 1",
"نکته عملی 2"
]
},
{
"type": "warning",
"title": "هشدار آبیاری",
"icon": "alert-triangle",
"icon": "tabler-alert-triangle",
"type": "warning",
"content": "هشدار کوتاه و کاربردی"
},
{
"title": "نکته بهره وری",
"icon": "tabler-bulb",
"type": "tip",
"content": "یک نکته عملی کوتاه"
}
]
}
### WRITING RULES
- If event dates are provided by the optimizer, mention them naturally inside `content` or `expandableExplanation`.
- If the optimizer says the advice is valid until rainfall, repeat that exact condition in `validityPeriod`.
- `plan.frequencyPerWeek`, `plan.bestTimeOfDay`, and the main warning must align with the optimizer block.
- `water_balance` must be included when FAO-56 or daily balance data is available, preserving the numeric values from the source context.
- `timeline` must be actionable and short. Use 2 to 4 steps when possible.
- If heat stress, rainfall risk, or unusual moisture is present, reflect it in a `warning` section.
- Put maintenance or efficiency advice inside `tip` sections.
- Never output markdown, code fences, greetings, or extra commentary.
@@ -0,0 +1,363 @@
# Irrigation Recommendation API Fields
این فایل فقط فیلدهای API مربوط به `POST /api/irrigation/recommend/` را توضیح می‌دهد.
## Endpoint
`POST /api/irrigation/recommend/`
## کاربرد
این endpoint برای تولید recommendation آبیاری استفاده می‌شود و خروجی آن با UI فعلی صفحه
`src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx`
هماهنگ شده است.
## ساختار کلی پاسخ
```json
{
"code": 200,
"msg": "success",
"data": {
"plan": {},
"water_balance": {},
"timeline": [],
"sections": []
}
}
```
## Request
### حداقل payload پیشنهادی
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
"growth_stage": "گلدهی",
"irrigation_type": "آبیاری قطره‌ای"
}
```
### فیلدهای Request
### `farm_uuid`
- نوع: `string`
- اجباری: بله
- توضیح: شناسه یکتای مزرعه.
### `sensor_uuid`
- نوع: `string`
- اجباری: خیر
- توضیح: نام قدیمی برای `farm_uuid`. اگر `farm_uuid` ارسال نشده باشد، این مقدار به جای آن استفاده می‌شود.
### `plant_name`
- نوع: `string`
- اجباری: خیر
- توضیح: نام گیاه هدف برای تولید توصیه. اگر ارسال نشود، سیستم در صورت امکان گیاه ثبت‌شده روی مزرعه را استفاده می‌کند.
### `growth_stage`
- نوع: `string`
- اجباری: خیر
- توضیح: مرحله رشد گیاه مثل `رویشی`، `گلدهی` یا `میوه‌دهی`.
### `irrigation_type`
- نوع: `string`
- اجباری: خیر
- توضیح: نوع یا نام روش آبیاری مورد نظر فرانت. این فیلد برای UI فعلی پیشنهاد می‌شود.
### `irrigation_method_name`
- نوع: `string`
- اجباری: خیر
- توضیح: نام روش آبیاری. این فیلد با `irrigation_type` هم‌ارز است و در بک‌اند به همان ورودی نهایی نرمال می‌شود.
## Response
## فیلدهای لایه اول Response
### `code`
- نوع: `number`
- توضیح: کد وضعیت پاسخ در قالب استاندارد API پروژه.
### `msg`
- نوع: `string`
- توضیح: پیام وضعیت پاسخ. در حالت موفق معمولاً `success` است.
### `data`
- نوع: `object`
- توضیح: بدنه اصلی recommendation آبیاری.
## فیلدهای `data`
### `plan`
- نوع: `object`
- توضیح: خلاصه اصلی recommendation برای نمایش در کارت بالای UI.
### `water_balance`
- نوع: `object`
- توضیح: تراز آب و خروجی محاسبات روزانه FAO-56.
### `timeline`
- نوع: `array`
- توضیح: مراحل اجرایی recommendation برای Stepper.
### `sections`
- نوع: `array`
- توضیح: نکات تکمیلی و هشدارها. در UI فعلی فقط `warning` و `tip` مصرف می‌شوند.
## فیلدهای `data.plan`
```json
{
"frequencyPerWeek": 4,
"durationMinutes": 38,
"bestTimeOfDay": "05:30 تا 08:00 صبح",
"moistureLevel": 72,
"warning": "در ساعات گرم روز آبیاری انجام نشود"
}
```
### `frequencyPerWeek`
- نوع: `number`
- توضیح: تعداد نوبت آبیاری در هفته.
### `durationMinutes`
- نوع: `number`
- توضیح: مدت هر نوبت آبیاری بر حسب دقیقه.
### `bestTimeOfDay`
- نوع: `string`
- توضیح: بهترین بازه زمانی اجرای آبیاری.
### `moistureLevel`
- نوع: `number`
- توضیح: سطح رطوبت فعلی یا هدف خاک برای نمایش در gauge.
### `warning`
- نوع: `string`
- توضیح: هشدار اصلی recommendation.
## فیلدهای `data.water_balance`
```json
{
"active_kc": 0.93,
"crop_profile": {
"kc_initial": 0.55,
"kc_mid": 1.05,
"kc_end": 0.78
},
"daily": [
{
"forecast_date": "2025-02-12",
"et0_mm": 5.4,
"etc_mm": 4.9,
"effective_rainfall_mm": 0,
"gross_irrigation_mm": 17,
"irrigation_timing": "05:30 - 07:00"
}
]
}
```
### `active_kc`
- نوع: `number`
- توضیح: ضریب Kc فعال برای مرحله رشد فعلی.
### `crop_profile`
- نوع: `object`
- توضیح: پروفایل Kc گیاه در مراحل مختلف.
### `daily`
- نوع: `array`
- توضیح: داده‌های روزانه مورد استفاده در جدول یا نمودار تراز آب.
## فیلدهای `data.water_balance.crop_profile`
### `kc_initial`
- نوع: `number`
- توضیح: Kc مرحله ابتدایی رشد.
### `kc_mid`
- نوع: `number`
- توضیح: Kc مرحله میانی رشد.
### `kc_end`
- نوع: `number`
- توضیح: Kc مرحله پایانی رشد.
## فیلدهای هر آیتم در `data.water_balance.daily[]`
### `forecast_date`
- نوع: `string`
- توضیح: تاریخ پیش‌بینی.
### `et0_mm`
- نوع: `number`
- توضیح: تبخیر و تعرق مرجع روزانه.
### `etc_mm`
- نوع: `number`
- توضیح: تبخیر و تعرق گیاه.
### `effective_rainfall_mm`
- نوع: `number`
- توضیح: بارش مؤثر محاسبه‌شده.
### `gross_irrigation_mm`
- نوع: `number`
- توضیح: مقدار آبیاری ناخالص پیشنهادی برای آن روز.
### `irrigation_timing`
- نوع: `string`
- توضیح: زمان پیشنهادی اجرای آبیاری برای آن روز.
## فیلدهای `data.timeline`
```json
[
{
"step_number": 1,
"title": "بررسی فشار",
"description": "فشار ابتدا و انتهای لاین کنترل شود"
}
]
```
### `step_number`
- نوع: `number`
- توضیح: شماره مرحله.
### `title`
- نوع: `string`
- توضیح: عنوان مرحله.
### `description`
- نوع: `string`
- توضیح: توضیح اجرایی مرحله.
## فیلدهای `data.sections`
```json
[
{
"title": "هشدار تبخیر بالا",
"icon": "tabler-alert-triangle",
"type": "warning",
"content": "در ساعات گرم روز آبیاری انجام نشود"
},
{
"title": "نکته بهره وری",
"icon": "tabler-bulb",
"type": "tip",
"content": "شست وشوی فیلترها به یکنواختی آبیاری کمک می کند"
}
]
```
### `title`
- نوع: `string`
- توضیح: عنوان کارت.
### `icon`
- نوع: `string`
- توضیح: نام آیکون مورد استفاده در UI.
### `type`
- نوع: `string`
- توضیح: نوع سکشن. در UI فعلی فقط این مقادیر مصرف می‌شوند:
- `warning`
- `tip`
### `content`
- نوع: `string`
- توضیح: متن هشدار یا نکته.
## حداقل پاسخ قابل استفاده برای UI فعلی
```json
{
"code": 200,
"msg": "success",
"data": {
"plan": {
"frequencyPerWeek": 4,
"durationMinutes": 38,
"bestTimeOfDay": "05:30 تا 08:00 صبح",
"moistureLevel": 72,
"warning": "در ساعات گرم روز آبیاری انجام نشود"
},
"water_balance": {
"active_kc": 0.93,
"crop_profile": {
"kc_initial": 0.55,
"kc_mid": 1.05,
"kc_end": 0.78
},
"daily": [
{
"forecast_date": "2025-02-12",
"et0_mm": 5.4,
"etc_mm": 4.9,
"effective_rainfall_mm": 0,
"gross_irrigation_mm": 17,
"irrigation_timing": "05:30 - 07:00"
}
]
},
"timeline": [
{
"step_number": 1,
"title": "بررسی فشار",
"description": "فشار ابتدا و انتهای لاین کنترل شود"
}
],
"sections": [
{
"title": "هشدار تبخیر بالا",
"icon": "tabler-alert-triangle",
"type": "warning",
"content": "در ساعات گرم روز آبیاری انجام نشود"
},
{
"title": "نکته بهره وری",
"icon": "tabler-bulb",
"type": "tip",
"content": "شست وشوی فیلترها به یکنواختی آبیاری کمک می کند"
}
]
}
}
```
## فیلدهایی که فرانت فعلی لازم ندارد
فیلدهای زیر برای UI فعلی recommendation لازم نیستند و نباید به عنوان dependency فرانت در نظر گرفته شوند:
- `raw_response`
- `status`
- `generated_at`
- `recommendation_title`
- `recommendation_subtitle`
- `final_verdict`
- `primary_method`
- `usage_summary`
- `alternative_plans`
- `sections[].type = schedule`
- `sections[].type = method`
## نمونه cURL
```bash
curl -s -X POST "http://localhost:8000/api/irrigation/recommend/" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
"growth_stage": "گلدهی",
"irrigation_type": "آبیاری قطره‌ای"
}'
```
@@ -0,0 +1,64 @@
from __future__ import annotations
from unittest.mock import patch
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
@override_settings(ROOT_URLCONF="irrigation.urls")
class IrrigationRecommendApiTests(TestCase):
def setUp(self):
self.client = APIClient()
@patch("rag.services.irrigation.get_irrigation_recommendation")
def test_recommend_api_returns_water_balance(self, mock_get_irrigation_recommendation):
mock_get_irrigation_recommendation.return_value = {
"plan": {
"frequencyPerWeek": 4,
"durationMinutes": 38,
"bestTimeOfDay": "05:30 تا 08:00 صبح",
"moistureLevel": 72,
"warning": "در ساعات گرم روز آبیاری انجام نشود",
},
"water_balance": {
"active_kc": 0.93,
"crop_profile": {
"kc_initial": 0.55,
"kc_mid": 1.05,
"kc_end": 0.78,
},
"daily": [
{
"forecast_date": "2025-02-12",
"et0_mm": 5.4,
"etc_mm": 4.9,
"effective_rainfall_mm": 0,
"gross_irrigation_mm": 17,
"irrigation_timing": "05:30 - 07:00",
}
],
},
"timeline": [],
"sections": [],
"simulation_optimizer": {"engine": "crop_simulation_heuristic"},
"selected_irrigation_method": {"name": "آبیاری قطره‌ای"},
}
response = self.client.post(
"/recommend/",
data={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
"growth_stage": "گلدهی",
"irrigation_method_name": "آبیاری قطره‌ای",
},
format="json",
)
self.assertEqual(response.status_code, 200)
data = response.json()["data"]
self.assertIn("water_balance", data)
self.assertEqual(data["water_balance"]["active_kc"], 0.93)
self.assertNotIn("simulation_optimizer", data)
self.assertNotIn("selected_irrigation_method", data)
-1
View File
@@ -44,7 +44,6 @@ WaterStressEnvelopeSerializer = build_envelope_serializer(
IRRIGATION_RECOMMENDATION_INTERNAL_KEYS = {
"raw_response",
"water_balance",
"simulation_optimizer",
"selected_irrigation_method",
}
+102
View File
@@ -1,3 +1,8 @@
from __future__ import annotations
import re
from functools import cached_property
from django.apps import AppConfig
@@ -5,3 +10,100 @@ class PlantConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "plant"
verbose_name = "Plant"
@cached_property
def plant_aliases(self) -> dict[str, str]:
return {
"tomato": "گوجه‌فرنگی",
"cucumber": "خیار",
"pepper": "فلفل دلمه‌ای",
"bell pepper": "فلفل دلمه‌ای",
"carrot": "هویج",
"lettuce": "کاهو",
"potato": "سیب‌زمینی",
"onion": "پیاز",
}
@cached_property
def growth_stage_aliases(self) -> dict[str, str]:
return {
"initial": "initial",
"seedling": "initial",
"establishment": "initial",
"جوانه زنی": "initial",
"جوانه‌زنی": "initial",
"نشا": "initial",
"استقرار": "initial",
"vegetative": "vegetative",
"growth": "vegetative",
"رویشی": "vegetative",
"رشد رویشی": "vegetative",
"flowering": "flowering",
"anthesis": "flowering",
"گلدهی": "flowering",
"گل دهی": "flowering",
"fruiting": "fruiting",
"harvest": "fruiting",
"ripening": "fruiting",
"میوه دهی": "fruiting",
"میوه‌دهی": "fruiting",
"برداشت": "fruiting",
"maturity": "maturity",
"رسیدگی": "maturity",
"بلوغ": "maturity",
}
def _normalize_lookup_value(self, value: str | None) -> str:
text = (value or "").strip().lower()
if not text:
return ""
translation_table = str.maketrans(
{
"ي": "ی",
"ك": "ک",
"ة": "ه",
"أ": "ا",
"إ": "ا",
"ؤ": "و",
"ۀ": "ه",
"": " ",
"-": " ",
"_": " ",
}
)
text = text.translate(translation_table)
text = re.sub(r"\s+", " ", text)
return text.strip()
def resolve_growth_stage(self, growth_stage: str | None) -> str | None:
value = (growth_stage or "").strip()
if not value:
return value
normalized = self._normalize_lookup_value(value)
return self.growth_stage_aliases.get(normalized, value)
def resolve_plant_name(self, plant_name: str | None) -> str | None:
from .models import Plant
value = (plant_name or "").strip()
if not value:
return value
plant = Plant.objects.filter(name=value).first() or Plant.objects.filter(name__iexact=value).first()
if plant is not None:
return plant.name
normalized = self._normalize_lookup_value(value)
alias_target = self.plant_aliases.get(normalized)
if alias_target:
aliased_plant = Plant.objects.filter(name=alias_target).first()
if aliased_plant is not None:
return aliased_plant.name
for plant in Plant.objects.only("name").iterator():
if self._normalize_lookup_value(plant.name) == normalized:
return plant.name
return value
+16 -8
View File
@@ -628,17 +628,25 @@ def get_fertilization_recommendation(
.first()
)
resolved_plant_name = plant_name
plant_config = apps.get_app_config("plant")
resolved_plant_name = plant_config.resolve_plant_name(plant_name)
if not resolved_plant_name and crop_id:
resolved_plant_name = crop_id
resolved_plant_name = plant_config.resolve_plant_name(crop_id)
resolved_growth_stage = plant_config.resolve_growth_stage(growth_stage)
plant = None
if not resolved_plant_name and sensor is not None:
plant = sensor.plants.first()
if plant is not None:
resolved_plant_name = plant.name
elif sensor is not None and resolved_plant_name:
plant = sensor.plants.filter(name=resolved_plant_name).first() or sensor.plants.first()
elif resolved_plant_name:
if sensor is not None:
plant = sensor.plants.filter(name=resolved_plant_name).first()
if plant is None:
Plant = apps.get_model("plant", "Plant")
plant = Plant.objects.filter(name=resolved_plant_name).first()
if plant is None and sensor is not None:
plant = sensor.plants.first()
if plant is not None:
resolved_plant_name = plant.name
@@ -658,7 +666,7 @@ def get_fertilization_recommendation(
sensor=sensor,
plant=plant,
forecasts=forecasts,
growth_stage=growth_stage,
growth_stage=resolved_growth_stage,
)
context = build_rag_context(
@@ -671,8 +679,8 @@ def get_fertilization_recommendation(
)
extra_parts: list[str] = []
if resolved_plant_name and growth_stage:
plant_text = build_plant_text(resolved_plant_name, growth_stage)
if resolved_plant_name and resolved_growth_stage:
plant_text = build_plant_text(resolved_plant_name, resolved_growth_stage)
if plant_text:
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
if optimized_result is not None:
@@ -721,7 +729,7 @@ def get_fertilization_recommendation(
optimized_result=optimized_result,
plant_name=resolved_plant_name,
crop_id=crop_id,
growth_stage=growth_stage,
growth_stage=resolved_growth_stage,
forecasts=forecasts,
)
result = _validate_fertilization_response(result)
+284 -37
View File
@@ -4,12 +4,18 @@
"""
import json
import logging
from typing import Any
from django.apps import apps
from django.db import transaction
from irrigation.models import IrrigationMethod
from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile, resolve_kc
from farm_data.models import SensorData
from irrigation.evapotranspiration import (
calculate_forecast_water_needs,
resolve_crop_profile,
resolve_kc,
)
from irrigation.models import IrrigationMethod
from rag.api_provider import get_chat_client
from rag.chat import (
_complete_audit_log,
@@ -18,8 +24,8 @@ from rag.chat import (
_load_service_tone,
build_rag_context,
)
from rag.config import load_rag_config, RAGConfig, get_service_config
from rag.user_data import build_plant_text, build_irrigation_method_text
from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.user_data import build_irrigation_method_text, build_plant_text
from weather.models import WeatherForecast
logger = logging.getLogger(__name__)
@@ -30,7 +36,7 @@ SERVICE_ID = "irrigation"
DEFAULT_IRRIGATION_PROMPT = (
"از محاسبات FAO-56 و خروجی بهینه ساز شبیه سازی برای ساخت توصیه آبیاری استفاده کن. "
"اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان را مرجع اصلی اعداد قرار بده. "
"پاسخ فقط JSON معتبر با کلید sections باشد و عدد جدید متناقض نساز."
"پاسخ را در قالب JSON معتبر با کلیدهای plan، timeline و sections برگردان و عدد جدید متناقض نساز."
)
@@ -38,24 +44,259 @@ def _get_optimizer():
return apps.get_app_config("crop_simulation").get_recommendation_optimizer()
def _validate_irrigation_response(parsed_result: dict) -> dict:
if not isinstance(parsed_result, dict):
raise ValueError("Irrigation recommendation response is not a JSON object.")
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
sections = parsed_result.get("sections")
if not isinstance(sections, list) or not sections:
raise ValueError("Irrigation recommendation response is missing sections.")
for index, section in enumerate(sections):
if not isinstance(section, dict):
raise ValueError(f"Irrigation recommendation section {index} is invalid.")
missing = [key for key in ("type", "title", "icon") if key not in section]
if missing:
raise ValueError(
f"Irrigation recommendation section {index} is missing fields: {', '.join(missing)}"
def _sensor_metric(sensor: SensorData | None, metric: str) -> float | None:
if sensor is None or not isinstance(sensor.sensor_payload, dict):
return None
for payload in sensor.sensor_payload.values():
if isinstance(payload, dict) and payload.get(metric) is not None:
return _safe_float(payload.get(metric), default=0.0)
return None
def _coerce_list(value: Any) -> list[Any]:
return value if isinstance(value, list) else []
def _coerce_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _estimate_duration_minutes(amount_per_event_mm: float, efficiency_percent: float | None) -> int:
normalized_efficiency = max(_safe_float(efficiency_percent, 75.0), 30.0)
estimated_minutes = round(max(amount_per_event_mm, 1.0) * (2400 / normalized_efficiency))
return max(10, min(estimated_minutes, 240))
def _default_warning(
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
soil_moisture: float | None,
) -> str:
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
reasoning = _coerce_list(strategy.get("reasoning"))
if reasoning:
return str(reasoning[0])
if soil_moisture is not None and soil_moisture < 25:
return "رطوبت خاک پایین است و نباید آبیاری به تعویق بیفتد."
if soil_moisture is not None and soil_moisture > 80:
return "رطوبت خاک بالاست و باید از آبیاری اضافی خودداری شود."
if any(_safe_float(item.get("effective_rainfall_mm")) > 0 for item in daily_water_needs):
return "با توجه به بارش موثر پیش بینی شده، برنامه آبیاری را قبل از اجرا دوباره بررسی کنید."
return "در ساعات گرم روز آبیاری انجام نشود."
def _normalize_plan(
llm_result: dict[str, Any],
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
irrigation_method: IrrigationMethod | None,
soil_moisture: float | None,
) -> dict[str, Any]:
llm_plan = _coerce_dict(llm_result.get("plan"))
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
frequency = llm_plan.get("frequencyPerWeek")
if frequency is None:
frequency = strategy.get("frequency_per_week") or strategy.get("events") or len(daily_water_needs) or 1
duration = llm_plan.get("durationMinutes")
if duration is None:
duration = _estimate_duration_minutes(
_safe_float(strategy.get("amount_per_event_mm"), 6.0),
getattr(irrigation_method, "water_efficiency_percent", None),
)
best_time = llm_plan.get("bestTimeOfDay")
if not best_time:
best_time = strategy.get("timing") or (
daily_water_needs[0].get("irrigation_timing") if daily_water_needs else "05:30 تا 08:00 صبح"
)
moisture_level = llm_plan.get("moistureLevel")
if moisture_level is None:
moisture_level = round(
soil_moisture
if soil_moisture is not None
else _safe_float(strategy.get("moisture_target_percent"), 70.0)
)
warning = llm_plan.get("warning")
if not warning:
warning = _default_warning(optimizer_result, daily_water_needs, soil_moisture)
return {
"frequencyPerWeek": int(max(_safe_float(frequency, 1), 1)),
"durationMinutes": int(max(_safe_float(duration, 10), 10)),
"bestTimeOfDay": str(best_time),
"moistureLevel": int(max(min(_safe_float(moisture_level, 70), 100), 0)),
"warning": str(warning),
}
def _normalize_timeline(
llm_result: dict[str, Any],
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
) -> list[dict[str, Any]]:
raw_timeline = _coerce_list(llm_result.get("timeline"))
timeline: list[dict[str, Any]] = []
for index, item in enumerate(raw_timeline, start=1):
item_dict = _coerce_dict(item)
title = item_dict.get("title")
description = item_dict.get("description")
if title and description:
timeline.append(
{
"step_number": int(item_dict.get("step_number") or index),
"title": str(title),
"description": str(description),
}
)
return parsed_result
if timeline:
return timeline
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
event_dates = _coerce_list(strategy.get("event_dates"))
best_timing = strategy.get("timing") or (
daily_water_needs[0].get("irrigation_timing") if daily_water_needs else "صبح زود"
)
generated = [
{
"step_number": 1,
"title": "بررسی فشار",
"description": "فشار ابتدا و انتهای لاین کنترل شود.",
},
{
"step_number": 2,
"title": "اجرای آبیاری",
"description": f"آبیاری در بازه {best_timing} انجام شود.",
},
]
if event_dates:
generated.append(
{
"step_number": 3,
"title": "پیگیری برنامه",
"description": f"نوبت های پیشنهادی برای تاریخ های {', '.join(map(str, event_dates))} بررسی شوند.",
}
)
else:
generated.append(
{
"step_number": 3,
"title": "بازبینی رطوبت",
"description": "بعد از هر نوبت، رطوبت خاک و یکنواختی توزیع آب کنترل شود.",
}
)
return generated
def _normalize_sections(
llm_result: dict[str, Any],
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
plan_warning: str,
) -> list[dict[str, Any]]:
raw_sections = _coerce_list(llm_result.get("sections"))
sections: list[dict[str, Any]] = []
for section in raw_sections:
item = _coerce_dict(section)
section_type = str(item.get("type") or "").strip().lower()
if section_type not in {"warning", "tip"}:
continue
content = item.get("content")
title = item.get("title")
if not content or not title:
continue
icon = item.get("icon") or (
"tabler-alert-triangle" if section_type == "warning" else "tabler-bulb"
)
sections.append(
{
"title": str(title),
"icon": str(icon),
"type": section_type,
"content": str(content),
}
)
if not any(item["type"] == "warning" for item in sections):
sections.insert(
0,
{
"title": "هشدار آبیاری",
"icon": "tabler-alert-triangle",
"type": "warning",
"content": plan_warning,
},
)
if not any(item["type"] == "tip" for item in sections):
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
reasoning = _coerce_list(strategy.get("reasoning"))
tip_content = (
str(reasoning[-1])
if reasoning
else "شست وشوی فیلترها و بازبینی یکنواختی پخش آب به پایداری برنامه آبیاری کمک می کند."
)
if any(_safe_float(item.get("effective_rainfall_mm")) > 0 for item in daily_water_needs):
tip_content = "قبل از نوبت بعدی، مقدار بارش موثر و رطوبت خاک را دوباره با برنامه تطبیق دهید."
sections.append(
{
"title": "نکته بهره وری",
"icon": "tabler-bulb",
"type": "tip",
"content": tip_content,
}
)
return sections[:4]
def _build_irrigation_ui_payload(
llm_result: dict[str, Any],
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
crop_profile: dict[str, Any],
active_kc: float,
irrigation_method: IrrigationMethod | None,
sensor: SensorData | None,
) -> dict[str, Any]:
soil_moisture = _sensor_metric(sensor, "soil_moisture")
plan = _normalize_plan(
llm_result,
optimizer_result,
daily_water_needs,
irrigation_method,
soil_moisture,
)
payload = {
"plan": plan,
"water_balance": {
"daily": daily_water_needs,
"crop_profile": crop_profile,
"active_kc": active_kc,
},
"timeline": _normalize_timeline(llm_result, optimizer_result, daily_water_needs),
"sections": _normalize_sections(
llm_result,
optimizer_result,
daily_water_needs,
plan["warning"],
),
}
return payload
def _resolve_irrigation_method(
@@ -146,6 +387,7 @@ def get_irrigation_recommendation(
plant = sensor.plants.first()
if plant is not None:
resolved_plant_name = plant.name
crop_profile = resolve_crop_profile(plant, growth_stage=growth_stage)
active_kc = resolve_kc(crop_profile, growth_stage=growth_stage)
forecasts = []
@@ -153,8 +395,10 @@ def get_irrigation_recommendation(
optimized_result = None
if sensor is not None:
forecasts = list(
WeatherForecast.objects.filter(location=sensor.center_location, forecast_date__isnull=False)
.order_by("forecast_date")[:7]
WeatherForecast.objects.filter(
location=sensor.center_location,
forecast_date__isnull=False,
).order_by("forecast_date")[:7]
)
efficiency_percent = (
getattr(irrigation_method, "water_efficiency_percent", None)
@@ -179,13 +423,16 @@ def get_irrigation_recommendation(
)
context = build_rag_context(
user_query, resolved_farm_uuid, config=cfg, limit=limit, kb_name=KB_NAME, service_id=SERVICE_ID,
user_query,
resolved_farm_uuid,
config=cfg,
limit=limit,
kb_name=KB_NAME,
service_id=SERVICE_ID,
)
extra_parts: list[str] = []
resolved_irrigation_method_name = (
irrigation_method.name if irrigation_method is not None else None
)
resolved_irrigation_method_name = irrigation_method.name if irrigation_method is not None else None
if resolved_plant_name and growth_stage:
plant_text = build_plant_text(resolved_plant_name, growth_stage)
if plant_text:
@@ -209,10 +456,7 @@ def get_irrigation_recommendation(
+ "\n".join(schedule_lines)
)
if optimized_result is not None:
extra_parts.append(
"[خروجی بهینه ساز شبیه سازی]\n"
+ optimized_result["context_text"]
)
extra_parts.append("[خروجی بهینه ساز شبیه سازی]\n" + optimized_result["context_text"])
if extra_parts:
context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "")
@@ -255,17 +499,20 @@ def get_irrigation_recommendation(
cleaned = raw
if cleaned.startswith("```"):
cleaned = cleaned.strip("`").removeprefix("json").strip()
result = json.loads(cleaned)
llm_result = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
result = {}
llm_result = {}
result = _validate_irrigation_response(result)
result = _build_irrigation_ui_payload(
_coerce_dict(llm_result),
optimized_result,
daily_water_needs,
crop_profile,
active_kc,
irrigation_method,
sensor,
)
result["raw_response"] = raw
result["water_balance"] = {
"daily": daily_water_needs,
"crop_profile": crop_profile,
"active_kc": active_kc,
}
result["simulation_optimizer"] = optimized_result
result["selected_irrigation_method"] = (
{
+87 -9
View File
@@ -28,6 +28,7 @@ class RecommendationServiceDefaultsTests(TestCase):
temperature_mean=18.0,
)
self.plant = Plant.objects.create(name="گوجه‌فرنگی")
self.onion = Plant.objects.create(name="پیاز")
self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای")
self.farm_uuid = uuid.uuid4()
self.farm = SensorData.objects.create(
@@ -69,13 +70,22 @@ class RecommendationServiceDefaultsTests(TestCase):
{
"code": "protective",
"label": "آبیاری حمایتی",
"score": 80.0,
"expected_yield_index": 85.0,
"total_irrigation_mm": 28.0,
"score": 80.0,
"expected_yield_index": 85.0,
"total_irrigation_mm": 28.0,
}
],
}
def build_irrigation_llm_result(self):
return (
'{"plan": {"frequencyPerWeek": 3, "durationMinutes": 42, "bestTimeOfDay": "اوایل صبح", '
'"moistureLevel": 68, "warning": "بررسی شود"}, '
'"timeline": [{"step_number": 1, "title": "بازبینی", "description": "لاین ها بررسی شوند"}], '
'"sections": [{"type": "warning", "title": "هشدار", "icon": "alert-triangle", "content": "بررسی شود"}, '
'{"type": "tip", "title": "نکته", "icon": "bulb", "content": "مورد سفارشی"}]}'
)
def build_fertilization_optimizer_result(self):
return {
"engine": "crop_simulation_heuristic",
@@ -130,7 +140,7 @@ class RecommendationServiceDefaultsTests(TestCase):
self.build_irrigation_optimizer_result()
)
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"sections": [{"type": "recommendation", "title": "برنامه", "icon": "droplet", "content": "custom"}, {"type": "list", "title": "custom", "icon": "list", "items": ["مورد سفارشی"]}, {"type": "warning", "title": "هشدار", "icon": "alert-triangle", "content": "بررسی شود"}]}'))]
mock_response.choices = [Mock(message=Mock(content=self.build_irrigation_llm_result()))]
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_irrigation_recommendation(
@@ -138,8 +148,8 @@ class RecommendationServiceDefaultsTests(TestCase):
growth_stage="میوه‌دهی",
)
self.assertEqual(result["sections"][0]["type"], "recommendation")
self.assertEqual(result["sections"][0]["content"], "custom")
self.assertEqual(result["plan"]["frequencyPerWeek"], 3)
self.assertEqual(result["plan"]["bestTimeOfDay"], "اوایل صبح")
mock_build_rag_context.assert_called_once()
mock_build_plant_text.assert_called_once_with("گوجه‌فرنگی", "میوه‌دهی")
mock_build_irrigation_method_text.assert_called_once_with("آبیاری قطره‌ای")
@@ -148,7 +158,9 @@ class RecommendationServiceDefaultsTests(TestCase):
"آبیاری قطره‌ای",
)
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
self.assertEqual(result["sections"][1]["items"], ["مورد سفارشی"])
self.assertEqual(result["timeline"][0]["title"], "بازبینی")
self.assertEqual(result["sections"][1]["type"], "tip")
self.assertEqual(result["water_balance"]["active_kc"], 0.9)
@patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[])
@patch("rag.services.irrigation.resolve_kc", return_value=0.9)
@@ -177,7 +189,7 @@ class RecommendationServiceDefaultsTests(TestCase):
self.build_irrigation_optimizer_result()
)
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"sections": [{"type": "recommendation", "title": "test", "icon": "droplet", "content": "custom"}, {"type": "list", "title": "گام ها", "icon": "list", "items": ["مورد 1"]}, {"type": "warning", "title": "هشدار", "icon": "alert-triangle", "content": "بررسی شود"}]}'))]
mock_response.choices = [Mock(message=Mock(content=self.build_irrigation_llm_result()))]
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_irrigation_recommendation(
@@ -190,7 +202,43 @@ class RecommendationServiceDefaultsTests(TestCase):
self.assertEqual(self.farm.irrigation_method_id, sprinkler.id)
self.assertEqual(result["selected_irrigation_method"]["id"], sprinkler.id)
mock_build_irrigation_method_text.assert_called_once_with("بارانی")
self.assertEqual(result["sections"][0]["content"], "custom")
self.assertEqual(result["plan"]["warning"], "بررسی شود")
@patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[])
@patch("rag.services.irrigation.resolve_kc", return_value=0.9)
@patch("rag.services.irrigation.resolve_crop_profile", return_value={})
@patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text")
@patch("rag.services.irrigation.build_plant_text", return_value="plant text")
@patch("rag.services.irrigation.build_rag_context", return_value="")
@patch("rag.services.irrigation._get_optimizer")
@patch("rag.services.irrigation.get_chat_client")
def test_irrigation_recommendation_falls_back_to_optimizer_when_llm_returns_invalid_payload(
self,
mock_get_chat_client,
mock_get_optimizer,
_mock_build_rag_context,
_mock_build_plant_text,
_mock_build_irrigation_method_text,
_mock_resolve_crop_profile,
_mock_resolve_kc,
_mock_calculate_forecast_water_needs,
):
mock_get_optimizer.return_value.optimize_irrigation.return_value = (
self.build_irrigation_optimizer_result()
)
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="not-json"))]
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_irrigation_recommendation(
farm_uuid=str(self.farm_uuid),
growth_stage="میوه‌دهی",
)
self.assertEqual(result["plan"]["frequencyPerWeek"], 3)
self.assertEqual(result["timeline"][0]["step_number"], 1)
self.assertEqual(result["sections"][0]["type"], "warning")
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
@patch("rag.services.fertilization.build_plant_text", return_value="plant text")
@patch("rag.services.fertilization.build_rag_context", return_value="")
@@ -221,6 +269,36 @@ class RecommendationServiceDefaultsTests(TestCase):
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
self.assertEqual(result["data"]["application_guide"]["safety_warning"], "از اختلاط نامناسب خودداری شود.")
@patch("rag.services.fertilization.build_plant_text", return_value="plant text")
@patch("rag.services.fertilization.build_rag_context", return_value="")
@patch("rag.services.fertilization._get_optimizer")
@patch("rag.services.fertilization.get_chat_client")
def test_fertilization_recommendation_resolves_requested_plant_from_catalog(
self,
mock_get_chat_client,
mock_get_optimizer,
_mock_build_rag_context,
mock_build_plant_text,
):
mock_get_optimizer.return_value.optimize_fertilization.return_value = (
self.build_fertilization_optimizer_result()
)
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="not-json"))]
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_fertilization_recommendation(
farm_uuid=str(self.farm_uuid),
plant_name="پیاز",
growth_stage="گلدهی",
)
optimizer_call = mock_get_optimizer.return_value.optimize_fertilization.call_args.kwargs
self.assertEqual(getattr(optimizer_call["plant"], "name", None), "پیاز")
self.assertEqual(optimizer_call["growth_stage"], "flowering")
mock_build_plant_text.assert_called_once_with("پیاز", "flowering")
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
@patch("rag.services.fertilization.build_plant_text", return_value="plant text")
@patch("rag.services.fertilization.build_rag_context", return_value="")
@patch("rag.services.fertilization._get_optimizer")