Files
Ai/rag/services/irrigation.py
T

547 lines
19 KiB
Python
Raw Normal View History

2026-03-19 22:54:29 +03:30
"""
سرویس توصیه آبیاری — بدون API، قابل فراخوانی از سایر سرویس‌ها
از RAG با پایگاه دانش irrigation و لحن مخصوص آبیاری استفاده می‌کند.
"""
import json
import logging
2026-04-28 19:00:38 +03:30
from typing import Any
2026-03-19 22:54:29 +03:30
2026-04-24 18:34:17 +03:30
from django.apps import apps
2026-04-24 03:02:22 +03:30
from django.db import transaction
2026-04-28 19:00:38 +03:30
2026-04-06 23:50:24 +03:30
from farm_data.models import SensorData
2026-05-05 01:46:10 +03:30
from farm_data.services import (
2026-05-13 16:45:54 +03:30
build_ai_farm_snapshot,
2026-05-05 01:46:10 +03:30
clone_snapshot_as_runtime_plant,
2026-05-13 16:45:54 +03:30
get_ai_snapshot_metric,
get_ai_snapshot_weather,
2026-05-05 01:46:10 +03:30
get_farm_plant_snapshot_by_name,
)
2026-04-28 19:00:38 +03:30
from irrigation.evapotranspiration import (
calculate_forecast_water_needs,
resolve_crop_profile,
resolve_kc,
)
from irrigation.models import IrrigationMethod
2026-03-19 22:54:29 +03:30
from rag.api_provider import get_chat_client
2026-04-24 02:12:06 +03:30
from rag.chat import (
_complete_audit_log,
_create_audit_log,
_fail_audit_log,
_load_service_tone,
build_rag_context,
)
2026-04-28 19:00:38 +03:30
from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.user_data import build_irrigation_method_text, build_plant_text
2026-03-22 03:08:27 +03:30
from weather.models import WeatherForecast
2026-03-19 22:54:29 +03:30
logger = logging.getLogger(__name__)
KB_NAME = "irrigation"
2026-03-22 03:08:27 +03:30
SERVICE_ID = "irrigation"
2026-03-19 22:54:29 +03:30
DEFAULT_IRRIGATION_PROMPT = (
2026-04-24 18:34:17 +03:30
"از محاسبات FAO-56 و خروجی بهینه ساز شبیه سازی برای ساخت توصیه آبیاری استفاده کن. "
"اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان را مرجع اصلی اعداد قرار بده. "
2026-04-28 19:00:38 +03:30
"پاسخ را در قالب JSON معتبر با کلیدهای plan، timeline و sections برگردان و عدد جدید متناقض نساز."
2026-03-19 22:54:29 +03:30
)
2026-04-24 18:34:17 +03:30
def _get_optimizer():
return apps.get_app_config("crop_simulation").get_recommendation_optimizer()
2026-04-28 19:00:38 +03:30
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
2026-05-13 16:45:54 +03:30
def _aggregated_metric(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
value = get_ai_snapshot_metric(ai_snapshot, metric)
return _safe_float(value, default=0.0) if value is not None else None
def _aggregated_metric_fallback(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
"""Limited fallback for missing aggregated metrics only; raw payload is intentionally not consulted."""
return _aggregated_metric(ai_snapshot, metric)
2026-04-28 19:00:38 +03:30
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 "در ساعات گرم روز آبیاری انجام نشود."
2026-04-24 18:34:17 +03:30
2026-04-27 18:02:26 +03:30
2026-04-28 19:00:38 +03:30
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),
}
2026-04-27 18:02:26 +03:30
)
2026-04-25 17:22:41 +03:30
2026-04-28 19:00:38 +03:30
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,
2026-05-13 16:45:54 +03:30
ai_snapshot: dict[str, Any] | None,
2026-04-28 19:00:38 +03:30
) -> dict[str, Any]:
2026-05-13 16:45:54 +03:30
soil_moisture = _aggregated_metric_fallback(ai_snapshot, "soil_moisture")
2026-04-28 19:00:38 +03:30
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
2026-04-24 18:34:17 +03:30
2026-04-24 03:02:22 +03:30
def _resolve_irrigation_method(
sensor: SensorData | None,
irrigation_method_name: str | None,
) -> IrrigationMethod | None:
if irrigation_method_name:
return IrrigationMethod.objects.filter(name=irrigation_method_name).first()
if sensor is not None:
return sensor.irrigation_method
return None
def _persist_irrigation_method_on_farm(
sensor: SensorData | None,
irrigation_method: IrrigationMethod | None,
) -> None:
if sensor is None or irrigation_method is None:
return
if sensor.irrigation_method_id == irrigation_method.id:
return
with transaction.atomic():
sensor.irrigation_method = irrigation_method
sensor.save(update_fields=["irrigation_method", "updated_at"])
2026-03-19 22:54:29 +03:30
def get_irrigation_recommendation(
2026-04-24 22:20:15 +03:30
farm_uuid: str | None = None,
2026-03-19 22:54:29 +03:30
plant_name: str | None = None,
growth_stage: str | None = None,
irrigation_method_name: str | None = None,
query: str | None = None,
config: RAGConfig | None = None,
limit: int = 8,
2026-04-24 22:20:15 +03:30
sensor_uuid: str | None = None,
2026-03-19 22:54:29 +03:30
) -> dict:
"""
2026-04-24 22:20:15 +03:30
توصیه آبیاری برای یک مزرعه.
2026-03-19 22:54:29 +03:30
از RAG با پایگاه دانش irrigation استفاده می‌کند.
Args:
2026-04-24 22:20:15 +03:30
farm_uuid: شناسه مزرعه
2026-03-19 22:54:29 +03:30
plant_name: نام گیاه (برای بارگذاری مشخصات از جدول Plant)
growth_stage: مرحله رشد گیاه
irrigation_method_name: نام روش آبیاری (برای بارگذاری از جدول IrrigationMethod)
query: سوال اختیاری
config: تنظیمات RAG
limit: تعداد چانک‌های بازیابی‌شده
Returns:
2026-04-24 22:20:15 +03:30
dict ساختاریافته برای توصیه آبیاری
2026-03-19 22:54:29 +03:30
"""
cfg = config or load_rag_config()
2026-03-22 03:08:27 +03:30
service = get_service_config(SERVICE_ID, cfg)
service_cfg = RAGConfig(
embedding=cfg.embedding,
qdrant=cfg.qdrant,
chunking=cfg.chunking,
llm=service.llm,
knowledge_bases=cfg.knowledge_bases,
services=cfg.services,
chromadb=cfg.chromadb,
)
client = get_chat_client(service_cfg)
model = service.llm.model
2026-03-19 22:54:29 +03:30
2026-04-24 22:20:15 +03:30
resolved_farm_uuid = str(farm_uuid or sensor_uuid or "").strip()
if not resolved_farm_uuid:
raise ValueError("farm_uuid is required.")
2026-03-19 22:54:29 +03:30
user_query = query or "توصیه آبیاری برای مزرعه من چیست؟"
2026-04-24 02:50:27 +03:30
sensor = (
SensorData.objects.select_related("center_location", "irrigation_method")
2026-05-05 01:46:10 +03:30
.prefetch_related("plant_assignments__plant")
2026-04-24 22:20:15 +03:30
.filter(farm_uuid=resolved_farm_uuid)
2026-04-24 02:50:27 +03:30
.first()
)
2026-05-13 16:45:54 +03:30
ai_snapshot = build_ai_farm_snapshot(resolved_farm_uuid)
2026-04-24 03:02:22 +03:30
irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name)
_persist_irrigation_method_on_farm(sensor, irrigation_method)
2026-03-22 03:08:27 +03:30
plant = None
2026-04-24 02:50:27 +03:30
resolved_plant_name = plant_name
2026-05-05 01:46:10 +03:30
if sensor is not None:
selected_snapshot = get_farm_plant_snapshot_by_name(sensor, plant_name)
plant = clone_snapshot_as_runtime_plant(
selected_snapshot,
growth_stage=growth_stage,
)
if selected_snapshot is not None:
resolved_plant_name = selected_snapshot.name
elif plant_name:
resolved_plant_name = plant_name
2026-04-28 19:00:38 +03:30
2026-03-22 03:08:27 +03:30
crop_profile = resolve_crop_profile(plant, growth_stage=growth_stage)
active_kc = resolve_kc(crop_profile, growth_stage=growth_stage)
forecasts = []
daily_water_needs = []
2026-04-24 18:34:17 +03:30
optimized_result = None
2026-03-22 03:08:27 +03:30
if sensor is not None:
forecasts = list(
2026-04-28 19:00:38 +03:30
WeatherForecast.objects.filter(
location=sensor.center_location,
forecast_date__isnull=False,
).order_by("forecast_date")[:7]
2026-03-22 03:08:27 +03:30
)
2026-04-24 03:02:22 +03:30
efficiency_percent = (
getattr(irrigation_method, "water_efficiency_percent", None)
if irrigation_method
else None
)
2026-03-22 03:08:27 +03:30
daily_water_needs = calculate_forecast_water_needs(
forecasts=forecasts,
2026-04-06 23:50:24 +03:30
latitude_deg=float(sensor.center_location.latitude),
2026-03-22 03:08:27 +03:30
crop_profile=crop_profile,
growth_stage=growth_stage,
irrigation_efficiency_percent=efficiency_percent,
)
2026-04-24 18:34:17 +03:30
if plant is not None and forecasts:
optimized_result = _get_optimizer().optimize_irrigation(
sensor=sensor,
2026-05-13 16:45:54 +03:30
ai_snapshot=ai_snapshot,
2026-04-24 18:34:17 +03:30
plant=plant,
forecasts=forecasts,
daily_water_needs=daily_water_needs,
growth_stage=growth_stage,
irrigation_method=irrigation_method,
)
2026-03-22 03:08:27 +03:30
2026-03-19 22:54:29 +03:30
context = build_rag_context(
2026-04-28 19:00:38 +03:30
user_query,
resolved_farm_uuid,
config=cfg,
limit=limit,
kb_name=KB_NAME,
service_id=SERVICE_ID,
2026-03-19 22:54:29 +03:30
)
extra_parts: list[str] = []
2026-04-28 19:00:38 +03:30
resolved_irrigation_method_name = irrigation_method.name if irrigation_method is not None else None
2026-04-24 02:50:27 +03:30
if resolved_plant_name and growth_stage:
plant_text = build_plant_text(resolved_plant_name, growth_stage)
2026-03-19 22:54:29 +03:30
if plant_text:
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
2026-04-24 02:50:27 +03:30
if resolved_irrigation_method_name:
method_text = build_irrigation_method_text(resolved_irrigation_method_name)
2026-03-19 22:54:29 +03:30
if method_text:
extra_parts.append("[روش آبیاری انتخابی]\n" + method_text)
2026-03-22 03:08:27 +03:30
if daily_water_needs:
total_mm = round(sum(item["gross_irrigation_mm"] for item in daily_water_needs), 2)
schedule_lines = [
f"- {item['forecast_date']}: ET0={item['et0_mm']} mm, ETc={item['etc_mm']} mm, "
f"بارش مؤثر={item['effective_rainfall_mm']} mm, نیاز آبی={item['gross_irrigation_mm']} mm, "
f"زمان پیشنهادی={item['irrigation_timing']}"
for item in daily_water_needs
]
extra_parts.append(
"[خروجی قطعی محاسبات FAO-56]\n"
f"کل نیاز آبی ۷ روز آینده: {total_mm} mm\n"
f"Kc مورد استفاده: {active_kc}\n"
+ "\n".join(schedule_lines)
)
2026-04-24 18:34:17 +03:30
if optimized_result is not None:
2026-04-28 19:00:38 +03:30
extra_parts.append("[خروجی بهینه ساز شبیه سازی]\n" + optimized_result["context_text"])
2026-03-19 22:54:29 +03:30
if extra_parts:
context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "")
2026-03-22 03:08:27 +03:30
tone = _load_service_tone(service, cfg)
2026-03-19 22:54:29 +03:30
system_parts = [tone] if tone else []
2026-03-22 03:08:27 +03:30
if service.system_prompt:
system_parts.append(service.system_prompt)
2026-03-19 22:54:29 +03:30
system_parts.append(DEFAULT_IRRIGATION_PROMPT)
if context:
system_parts.append("\n\n" + context)
system_content = "\n".join(system_parts)
messages = [
{"role": "system", "content": system_content},
{"role": "user", "content": user_query},
]
2026-04-24 02:12:06 +03:30
audit_log = _create_audit_log(
2026-04-24 22:20:15 +03:30
farm_uuid=resolved_farm_uuid,
2026-04-24 02:12:06 +03:30
service_id=SERVICE_ID,
model=model,
query=user_query,
system_prompt=system_content,
messages=messages,
)
2026-03-19 22:54:29 +03:30
try:
response = client.chat.completions.create(
model=model,
messages=messages,
)
raw = response.choices[0].message.content.strip()
except Exception as exc:
2026-04-24 22:20:15 +03:30
logger.error("Irrigation recommendation error for %s: %s", resolved_farm_uuid, exc)
2026-04-27 18:02:26 +03:30
_fail_audit_log(audit_log, str(exc))
raise RuntimeError(
f"Irrigation recommendation failed for farm {resolved_farm_uuid}."
) from exc
2026-03-19 22:54:29 +03:30
try:
cleaned = raw
if cleaned.startswith("```"):
cleaned = cleaned.strip("`").removeprefix("json").strip()
2026-04-28 19:00:38 +03:30
llm_result = json.loads(cleaned)
2026-03-19 22:54:29 +03:30
except (json.JSONDecodeError, ValueError):
2026-04-28 19:00:38 +03:30
llm_result = {}
2026-03-19 22:54:29 +03:30
2026-04-28 19:00:38 +03:30
result = _build_irrigation_ui_payload(
_coerce_dict(llm_result),
optimized_result,
daily_water_needs,
crop_profile,
active_kc,
irrigation_method,
2026-05-13 16:45:54 +03:30
ai_snapshot,
2026-04-28 19:00:38 +03:30
)
2026-03-19 22:54:29 +03:30
result["raw_response"] = raw
2026-04-24 18:34:17 +03:30
result["simulation_optimizer"] = optimized_result
2026-04-24 03:02:22 +03:30
result["selected_irrigation_method"] = (
{
"id": irrigation_method.id,
"name": irrigation_method.name,
"category": irrigation_method.category,
"water_efficiency_percent": irrigation_method.water_efficiency_percent,
}
if irrigation_method is not None
else None
)
2026-04-24 02:12:06 +03:30
_complete_audit_log(
audit_log,
json.dumps(result, ensure_ascii=False, default=str),
)
2026-03-19 22:54:29 +03:30
return result