This commit is contained in:
2026-04-30 02:10:15 +03:30
parent 46ba01e4cc
commit e2c70ec8b6
5 changed files with 141 additions and 94 deletions
+33 -11
View File
@@ -30,11 +30,33 @@ DEFAULT_STAGE_LABELS = {
"maturity": "رسیدگی",
}
ENGINE_LABELS = {
"pcse": "موتور شبیه سازی PCSE",
"growth_projection": "موتور برآورد رشد",
}
MODEL_LABELS = {
"growth_projection_v1": "مدل برآورد رشد نسخه ۱",
"wofost": "مدل ووفوست",
}
class GrowthSimulationError(Exception):
pass
def _fa_engine_name(name: str | None) -> str | None:
if not name:
return name
return ENGINE_LABELS.get(name, name)
def _fa_model_name(name: str | None) -> str | None:
if not name:
return name
return MODEL_LABELS.get(name, name)
@dataclass
class GrowthSimulationContext:
farm_uuid: str | None
@@ -469,7 +491,7 @@ def _run_simulation(context: GrowthSimulationContext) -> tuple[dict[str, Any], i
exc,
)
fallback_result = _run_projection_engine(context)
warning = f"Simulation engine failed, fallback projection used: {exc}"
warning = f"موتور شبیه سازی با خطا مواجه شد و برآورد جایگزین استفاده شد: {exc}"
return fallback_result, None, warning
@@ -622,8 +644,8 @@ def run_growth_simulation(payload: dict[str, Any], progress_callback=None) -> di
return {
"plant_name": context.plant_name,
"dynamic_parameters": context.dynamic_parameters,
"engine": simulation_result.get("engine"),
"model_name": simulation_result.get("model_name"),
"engine": _fa_engine_name(simulation_result.get("engine")),
"model_name": _fa_model_name(simulation_result.get("model_name")),
"scenario_id": scenario_id,
"simulation_warning": simulation_error,
"summary_metrics": simulation_result.get("metrics", {}),
@@ -665,7 +687,7 @@ def _build_current_farm_chart_payload(
"title": "تعداد برگ تخمینی",
"subtitle": "وضعیت فعلی",
"amount": round(_estimate_leaf_count(latest_lai), 2),
"unit": "leaf",
"unit": "برگ",
"avatarColor": "success",
"avatarIcon": "tabler-leaf",
},
@@ -673,7 +695,7 @@ def _build_current_farm_chart_payload(
"title": "وزن بیوماس",
"subtitle": "برآورد فعلی",
"amount": round(latest_biomass, 2),
"unit": "kg/ha",
"unit": "کیلوگرم در هکتار",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-bar",
},
@@ -681,7 +703,7 @@ def _build_current_farm_chart_payload(
"title": "وزن محصول",
"subtitle": "برآورد فعلی",
"amount": round(latest_storage, 2),
"unit": "kg/ha",
"unit": "کیلوگرم در هکتار",
"avatarColor": "warning",
"avatarIcon": "tabler-scale",
},
@@ -698,8 +720,8 @@ def _build_current_farm_chart_payload(
return {
"farm_uuid": context.farm_uuid,
"plant_name": context.plant_name,
"engine": simulation_result.get("engine"),
"model_name": simulation_result.get("model_name"),
"engine": _fa_engine_name(simulation_result.get("engine")),
"model_name": _fa_model_name(simulation_result.get("model_name")),
"scenario_id": scenario_id,
"simulation_warning": simulation_warning,
"categories": categories,
@@ -731,7 +753,7 @@ class CurrentFarmChartSimulator:
def simulate(self, *, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
if not farm_uuid:
raise GrowthSimulationError("farm_uuid is required.")
raise GrowthSimulationError("ارسال farm_uuid الزامی است.")
resolved_plant_name = plant_name
if not resolved_plant_name:
@@ -741,10 +763,10 @@ class CurrentFarmChartSimulator:
.first()
)
if sensor is None:
raise GrowthSimulationError("Farm not found.")
raise GrowthSimulationError("مزرعه پیدا نشد.")
plant = sensor.plants.first()
if plant is None:
raise GrowthSimulationError("Plant not found for the selected farm.")
raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.")
resolved_plant_name = plant.name
context = build_growth_context(
+3 -3
View File
@@ -51,10 +51,10 @@ def build_harvest_prediction_payload(*, farm_uuid: str, plant_name: str | None =
if not resolved_plant_name:
sensor = SensorData.objects.prefetch_related("plants").filter(farm_uuid=farm_uuid).first()
if sensor is None:
raise GrowthSimulationError("Farm not found.")
raise GrowthSimulationError("مزرعه پیدا نشد.")
plant = sensor.plants.first()
if plant is None:
raise GrowthSimulationError("Plant not found for the selected farm.")
raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.")
resolved_plant_name = plant.name
context = build_growth_context(
@@ -68,7 +68,7 @@ def build_harvest_prediction_payload(*, farm_uuid: str, plant_name: str | None =
simulation_result, scenario_id, simulation_warning = _run_simulation(context)
daily_output = simulation_result.get("daily_output") or []
if not daily_output:
raise GrowthSimulationError("No simulation output available.")
raise GrowthSimulationError("هیچ خروجی شبیه سازی در دسترس نیست.")
profile = resolve_growth_profile(context.plant)
required_gdd = _safe_float(profile.get("required_gdd_for_maturity"), 1200.0)
+19 -6
View File
@@ -63,6 +63,15 @@ def _coerce_positive_int(value, default: int) -> int:
return max(parsed, 1)
def _fa_task_status(status_name: str) -> str:
return {
"PENDING": "در انتظار",
"PROGRESS": "در حال پردازش",
"SUCCESS": "موفق",
"FAILURE": "ناموفق",
}.get(status_name, status_name)
class PlantGrowthSimulationView(APIView):
@extend_schema(
tags=["Crop Simulation"],
@@ -153,7 +162,11 @@ class PlantGrowthSimulationStatusView(APIView):
)
def get(self, request, task_id: str):
result = _get_async_result(task_id)
payload = {"task_id": task_id, "status": result.state}
payload = {
"task_id": task_id,
"status": result.state,
"status_fa": _fa_task_status(result.state),
}
if result.state == "PENDING":
payload["message"] = "تسک در صف یا یافت نشد."
@@ -181,7 +194,7 @@ class PlantGrowthSimulationStatusView(APIView):
payload["error"] = str(result.result)
return Response(
{"code": 200, "msg": "success", "data": payload},
{"code": 200, "msg": "موفق", "data": payload},
status=status.HTTP_200_OK,
)
@@ -256,7 +269,7 @@ class CurrentFarmSimulationChartView(APIView):
)
return Response(
{"code": 200, "msg": "success", "data": result},
{"code": 200, "msg": "موفق", "data": result},
status=status.HTTP_200_OK,
)
@@ -313,7 +326,7 @@ class HarvestPredictionView(APIView):
)
return Response(
{"code": 200, "msg": "success", "data": result},
{"code": 200, "msg": "موفق", "data": result},
status=status.HTTP_200_OK,
)
@@ -355,7 +368,7 @@ class YieldPredictionView(APIView):
{"code": 500, "msg": f"خطا در پیش بینی عملکرد: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK)
return Response({"code": 200, "msg": "موفق", "data": result}, status=status.HTTP_200_OK)
class YieldHarvestSummaryView(APIView):
@@ -443,4 +456,4 @@ class YieldHarvestSummaryView(APIView):
crop_name=validated.get("crop_name") or "",
include_narrative=validated.get("include_narrative", False),
)
return Response({"code": 200, "msg": "success", "data": payload}, status=status.HTTP_200_OK)
return Response({"code": 200, "msg": "موفق", "data": payload}, status=status.HTTP_200_OK)
+77 -70
View File
@@ -17,6 +17,13 @@ from rag.services.yield_harvest import YieldHarvestRAGService
logger = logging.getLogger(__name__)
READINESS_STATUS_FA = {
"ready": "آماده",
"approaching": "نزدیک به آمادگی",
"monitoring": "نیازمند پایش",
"not_ready": "آماده نیست",
}
class YieldHarvestSummaryService:
def get_summary(
@@ -174,8 +181,8 @@ class YieldHarvestSummaryService:
)
fallback_description = (
f"Deterministic harvest forecast for {crop_name or 'the selected crop'} "
f"in season {season_year}."
f"پیش بینی قطعی برداشت برای {crop_name or 'محصول انتخاب شده'} "
f"در فصل زراعی {season_year}."
)
return {
@@ -188,7 +195,7 @@ class YieldHarvestSummaryService:
"optimal_window_start": result.get("optimalWindowStart"),
"optimal_window_end": result.get("optimalWindowEnd"),
"description": result.get("description") or fallback_description,
"descriptionSource": "deterministic",
"descriptionSource": "قطعی",
"field_conditions": {
"soil_moisture": farm_context.get("recent_sensor_averages", {}).get("soil_moisture"),
"soil_temperature": farm_context.get("recent_sensor_averages", {}).get("soil_temperature"),
@@ -233,19 +240,19 @@ class YieldHarvestSummaryService:
"season_year": season_year,
"series": [
{
"name": "Predicted Yield",
"name": "عملکرد پیش بینی شده",
"type": "line",
"data": yield_series,
},
{
"name": "Biomass",
"name": "بیوماس",
"type": "area",
"data": biomass_series,
},
],
"xAxis": {"type": "datetime"},
"xAxis": {"type": "datetime", "label": "تاریخ"},
"meta": {
"unit": "kg/ha",
"unit": "کیلوگرم در هکتار",
"simulation_engine": result.get("engine"),
"simulation_model": result.get("model_name"),
"scenario_id": result.get("scenario_id"),
@@ -281,10 +288,10 @@ class YieldHarvestSummaryService:
"days_until_harvest": days_until,
"current_dvs": round(pcse_dvs_stage, 4),
"summary": (
f"Operations are prioritized for {farm_context.get('crop_name') or 'the selected crop'} "
f"with {days_until} days remaining until the predicted harvest window."
f"عملیات برداشت برای {farm_context.get('crop_name') or 'محصول انتخاب شده'} "
f"با توجه به {days_until} روز باقی مانده تا بازه پیش بینی شده برداشت اولویت بندی شده است."
),
"rules_source": "deterministic_dvs_rules",
"rules_source": "قواعد_قطعی_DVS",
"field_context": {
"soil_type": farm_context.get("soil", {}).get("soil_type"),
"soil_moisture": farm_context.get("recent_sensor_averages", {}).get("soil_moisture"),
@@ -325,7 +332,7 @@ class YieldHarvestSummaryService:
"farm_uuid": farm_uuid,
"crop_name": crop_name,
"season_year": season_year,
"title": "Season highlights",
"title": "خلاصه فصل",
# Left blank for narrative merge unless a non-LLM fallback is needed later.
"subtitle": "",
"total_predicted_yield": total_predicted_yield,
@@ -357,7 +364,7 @@ class YieldHarvestSummaryService:
"farm_uuid": farm_uuid,
"averageReadiness": None,
"zones": [],
"source": "ndvi_health_service",
"source": "سرویس_سلامت_NDVI",
}
location = sensor.center_location
@@ -398,7 +405,7 @@ class YieldHarvestSummaryService:
zones.append(
{
"zoneId": f"zone-{zone_index}",
"zoneLabel": f"Zone {zone_index}",
"zoneLabel": f"ناحیه {zone_index}",
"gridPosition": {"row": row_index, "col": col_index},
"meanNdvi": cell_ndvi,
"readiness": readiness,
@@ -413,7 +420,7 @@ class YieldHarvestSummaryService:
zones.append(
{
"zoneId": "zone-center",
"zoneLabel": "Center field zone",
"zoneLabel": "ناحیه مرکزی مزرعه",
"gridPosition": None,
"meanNdvi": latest_ndvi,
"readiness": readiness,
@@ -441,7 +448,7 @@ class YieldHarvestSummaryService:
"ndviTrend": ndvi_trend,
"averageReadiness": average_readiness,
"zones": zones,
"source": "ndvi_health_service",
"source": "سرویس_سلامت_NDVI",
}
def _to_unix_timestamp(self, value: Any) -> int | None:
@@ -480,12 +487,12 @@ class YieldHarvestSummaryService:
def _readiness_status(self, readiness: int) -> str:
if readiness >= 80:
return "ready"
return READINESS_STATUS_FA["ready"]
if readiness >= 55:
return "approaching"
return READINESS_STATUS_FA["approaching"]
if readiness >= 30:
return "monitoring"
return "not_ready"
return READINESS_STATUS_FA["monitoring"]
return READINESS_STATUS_FA["not_ready"]
def _build_yield_quality_bands(
self,
@@ -555,7 +562,7 @@ class YieldHarvestSummaryService:
"farm_uuid": farm_uuid,
"crop_name": crop_name,
"season_year": season_year,
"source": "deterministic_grading_rules",
"source": "قواعد_قطعی_درجه_بندی",
"is_estimated": True,
"protein_content": {
"value": protein_content,
@@ -568,7 +575,7 @@ class YieldHarvestSummaryService:
"grade_distribution": grade_distribution,
"primary_quality_grade": primary_quality_grade,
"quality_score": quality_score,
"summary": f"Primary quality grade is {primary_quality_grade}.",
"summary": f"درجه کیفیت غالب محصول {primary_quality_grade} است.",
}
def _get_estimated_revenue(
@@ -682,7 +689,7 @@ class YieldHarvestSummaryService:
return {
"farm_uuid": farm_uuid,
"center_coordinates": None,
"soil": {"provider": getattr(settings, "SOIL_DATA_PROVIDER", "unknown")},
"soil": {"provider": getattr(settings, "SOIL_DATA_PROVIDER", "نامشخص")},
"recent_sensor_averages": {},
}
@@ -712,7 +719,7 @@ class YieldHarvestSummaryService:
},
"farm_boundary": farm_details.get("center_location", {}).get("farm_boundary"),
"soil": {
"provider": getattr(settings, "SOIL_DATA_PROVIDER", "unknown"),
"provider": getattr(settings, "SOIL_DATA_PROVIDER", "نامشخص"),
"soil_type": self._infer_soil_type(soil_details),
"resolved_metrics": soil_details,
},
@@ -735,14 +742,14 @@ class YieldHarvestSummaryService:
def _map_dvs_to_phase(self, dvs: float) -> tuple[str, str]:
if dvs >= 2.0:
return "ready", "maturity"
return "آماده", "رسیدگی"
if dvs >= 1.7:
return "final_pre_harvest", "late_reproductive"
return "پیش_برداشت_نهایی", "زایشی_پایانی"
if dvs >= 1.2:
return "mid_pre_harvest", "grain_fill"
return "پیش_برداشت_میانی", "پرشدن_دانه"
if dvs >= 0.8:
return "monitoring", "reproductive_transition"
return "early_pre_harvest", "vegetative"
return "پایش", "گذار_زایشی"
return "پیش_برداشت_ابتدایی", "رشد_رویشی"
def _build_operations_steps(
self,
@@ -753,74 +760,74 @@ class YieldHarvestSummaryService:
) -> list[dict[str, Any]]:
field_ready = soil_moisture is None or soil_moisture <= 35.0
if phase_name == "maturity":
if phase_name == "رسیدگی":
return [
{
"key": "desiccation",
"title": "Desiccation check",
"status": "ready",
"title": "بررسی خشک شدن محصول",
"status": "آماده",
"is_completed": False,
"estimated_days": 0,
},
{
"key": "harvesting",
"title": "Harvesting",
"status": "ready" if field_ready else "watch_field_conditions",
"title": "برداشت",
"status": "آماده" if field_ready else "نیازمند بررسی شرایط مزرعه",
"is_completed": False,
"estimated_days": max(min(days_until, 2), 0),
},
{
"key": "transportation",
"title": "Transportation",
"status": "ready",
"title": "انتقال محصول",
"status": "آماده",
"is_completed": False,
"estimated_days": max(min(days_until + 1, 3), 1),
},
]
if phase_name == "late_reproductive":
if phase_name == "زایشی_پایانی":
return [
{
"key": "equipment_check",
"title": "Inspect harvest equipment",
"status": "priority",
"title": "بازبینی تجهیزات برداشت",
"status": "اولویت بالا",
"is_completed": False,
"estimated_days": 1,
},
{
"key": "labor_plan",
"title": "Confirm labor and transport plan",
"status": "priority",
"title": "نهایی کردن برنامه نیروی کار و حمل",
"status": "اولویت بالا",
"is_completed": False,
"estimated_days": 2,
},
{
"key": "field_entry",
"title": "Verify field access and dry windows",
"status": "ready" if field_ready else "monitor",
"title": "بررسی امکان ورود به مزرعه و بازه های خشک",
"status": "آماده" if field_ready else "پایش",
"is_completed": False,
"estimated_days": max(min(days_until, 5), 1),
},
]
if phase_name == "grain_fill":
if phase_name == "پرشدن_دانه":
return [
{
"key": "monitor_maturity",
"title": "Track maturity and storage organ growth",
"status": "active",
"title": "پایش رسیدگی و رشد اندام ذخیره ای",
"status": "در حال انجام",
"is_completed": False,
"estimated_days": 7,
},
{
"key": "review_readiness",
"title": "Review zone readiness differences",
"status": "active",
"title": "بررسی اختلاف آمادگی بین ناحیه ها",
"status": "در حال انجام",
"is_completed": False,
"estimated_days": 10,
},
{
"key": "prepare_logistics",
"title": "Prepare harvest logistics plan",
"status": "upcoming",
"title": "آماده سازی برنامه لجستیک برداشت",
"status": "پیش رو",
"is_completed": False,
"estimated_days": 14,
},
@@ -828,22 +835,22 @@ class YieldHarvestSummaryService:
return [
{
"key": "weekly_monitoring",
"title": "Run weekly crop maturity checks",
"status": "active",
"title": "پایش هفتگی رسیدگی محصول",
"status": "در حال انجام",
"is_completed": False,
"estimated_days": 14,
},
{
"key": "update_forecast",
"title": "Refresh harvest timing forecast",
"status": "active",
"title": "به روزرسانی پیش بینی زمان برداشت",
"status": "در حال انجام",
"is_completed": False,
"estimated_days": 10,
},
{
"key": "draft_operations",
"title": "Draft harvest operation checklist",
"status": "upcoming",
"title": "تهیه چک لیست عملیات برداشت",
"status": "پیش رو",
"is_completed": False,
"estimated_days": 21,
},
@@ -856,12 +863,12 @@ class YieldHarvestSummaryService:
if sand is None or clay is None or silt is None:
return None
if clay >= 40:
return "clay"
return "رسی"
if sand >= 70 and clay <= 15:
return "sandy"
return "شنی"
if silt >= 50 and clay < 27:
return "silty_loam"
return "loam"
return "سیلتی لوم"
return "لوم"
def _safe_float(self, value: Any, default: float | None = 0.0) -> float | None:
try:
@@ -932,33 +939,33 @@ class YieldHarvestSummaryService:
highlights = payload.get("season_highlights_card") or {}
total_yield = highlights.get("total_predicted_yield")
unit = highlights.get("yield_unit") or ""
harvest_date = highlights.get("target_harvest_date") or "the predicted harvest window"
harvest_date = highlights.get("target_harvest_date") or "بازه پیش بینی شده برداشت"
if total_yield is None:
return f"Harvest is targeted for {harvest_date} based on the deterministic season outlook."
return f"Predicted yield is {total_yield} {unit} and harvest is targeted for {harvest_date}.".strip()
return f"بر اساس چشم انداز قطعی فصل، برداشت برای {harvest_date} هدف گذاری شده است."
return f"عملکرد پیش بینی شده {total_yield} {unit} است و برداشت برای {harvest_date} هدف گذاری شده است.".strip()
def _default_yield_prediction_explanation(self, payload: dict[str, Any]) -> str:
yield_card = payload.get("yield_prediction") or {}
predicted = yield_card.get("predicted_yield_tons")
unit = yield_card.get("unit") or ""
if predicted is None:
return "Yield forecast is based on the deterministic crop simulation output."
return f"Yield forecast is based on the deterministic crop simulation and currently projects {predicted} {unit}.".strip()
return "پیش بینی عملکرد بر پایه خروجی قطعی شبیه سازی محصول محاسبه شده است."
return f"پیش بینی عملکرد بر پایه شبیه سازی قطعی محصول انجام شده و در حال حاضر مقدار {predicted} {unit} را نشان می دهد.".strip()
def _default_harvest_readiness_summary(self, payload: dict[str, Any]) -> str:
readiness = payload.get("harvest_readiness_zones") or {}
average = readiness.get("averageReadiness")
if average is None:
return "Harvest readiness is derived from the latest deterministic zone signals."
return f"Average harvest readiness is {average} based on the latest deterministic zone signals.".strip()
return "آمادگی برداشت از آخرین سیگنال های قطعی ناحیه ای استخراج شده است."
return f"میانگین آمادگی برداشت بر اساس آخرین سیگنال های قطعی ناحیه ای، {average} است.".strip()
def _default_operation_note(self, step: dict[str, Any]) -> str:
title = step.get("title") or "This operation"
status = step.get("status") or "planned"
title = step.get("title") or "این عملیات"
status = step.get("status") or "برنامه ریزی شده"
estimate = step.get("estimated_days")
if estimate is None:
return f"{title} is currently marked as {status}."
return f"{title} is {status} with an estimated timing of {estimate} days.".strip()
return f"وضعیت {title} در حال حاضر «{status}» ثبت شده است."
return f"{title} با وضعیت «{status}» و زمان بندی تقریبی {estimate} روز ثبت شده است.".strip()
def _resolve_service(self, *, getter_names: tuple[str, ...]) -> Any:
app_config = apps.get_app_config("crop_simulation")
+9 -4
View File
@@ -2,7 +2,12 @@ from __future__ import annotations
from typing import Any
from .growth_simulation import CurrentFarmChartSimulator, GrowthSimulationError
from .growth_simulation import (
CurrentFarmChartSimulator,
GrowthSimulationError,
_fa_engine_name,
_fa_model_name,
)
def build_yield_prediction_payload(*, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
@@ -16,9 +21,9 @@ def build_yield_prediction_payload(*, farm_uuid: str, plant_name: str | None = N
"predictedYieldTons": predicted_yield_tons,
"predictedYieldRaw": round(yield_estimate, 2),
"unit": "تن",
"sourceUnit": "kg/ha",
"simulationEngine": result.get("engine"),
"simulationModel": result.get("model_name"),
"sourceUnit": "کیلوگرم در هکتار",
"simulationEngine": _fa_engine_name(result.get("engine")),
"simulationModel": _fa_model_name(result.get("model_name")),
"scenarioId": result.get("scenario_id"),
"simulationWarning": result.get("simulation_warning"),
"supportingMetrics": result.get("metrics") or {},