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
+36
View File
@@ -14,5 +14,41 @@ class CropSimulationConfig(AppConfig):
return SimulationRecommendationOptimizer()
@cached_property
def current_farm_chart_simulator(self):
from .growth_simulation import CurrentFarmChartSimulator
return CurrentFarmChartSimulator()
@cached_property
def harvest_prediction_service(self):
from .harvest_prediction import HarvestPredictionService
return HarvestPredictionService()
@cached_property
def yield_prediction_service(self):
from .yield_prediction import YieldPredictionService
return YieldPredictionService()
@cached_property
def water_stress_service(self):
from .water_stress import WaterStressSimulationService
return WaterStressSimulationService()
def get_recommendation_optimizer(self):
return self.recommendation_optimizer
def get_current_farm_chart_simulator(self):
return self.current_farm_chart_simulator
def get_harvest_prediction_service(self):
return self.harvest_prediction_service
def get_yield_prediction_service(self):
return self.yield_prediction_service
def get_water_stress_service(self):
return self.water_stress_service
+130 -2
View File
@@ -402,8 +402,7 @@ def _run_simulation(context: GrowthSimulationContext) -> tuple[dict[str, Any], i
)
return response["result"], response.get("scenario_id"), None
except Exception as exc:
fallback = _run_projection_engine(context)
return fallback, None, str(exc)
raise GrowthSimulationError(f"Simulation engine failed: {exc}") from exc
def summarize_growth_stages(
@@ -566,3 +565,132 @@ def run_growth_simulation(payload: dict[str, Any], progress_callback=None) -> di
"daily_records_count": len(simulation_result.get("daily_output", [])),
"default_page_size": context.page_size,
}
def _estimate_leaf_count(lai: float) -> float:
return max(lai, 0.0) * 12000.0
def _build_current_farm_chart_payload(
context: GrowthSimulationContext,
simulation_result: dict[str, Any],
scenario_id: int | None,
simulation_warning: str | None,
) -> dict[str, Any]:
daily_output = simulation_result.get("daily_output") or []
categories = [str(item.get("DAY")) for item in daily_output]
leaf_count_series = [round(_estimate_leaf_count(_safe_float(item.get("LAI"))), 2) for item in daily_output]
biomass_series = [round(_safe_float(item.get("TAGP")), 2) for item in daily_output]
storage_weight_series = [round(_safe_float(item.get("TWSO")), 2) for item in daily_output]
lai_series = [round(_safe_float(item.get("LAI")), 4) for item in daily_output]
moisture_series = [round(_safe_float(item.get("SM")) * 100.0, 2) for item in daily_output]
latest = daily_output[-1] if daily_output else {}
latest_lai = _safe_float(latest.get("LAI"), 0.0)
latest_biomass = _safe_float(latest.get("TAGP"), 0.0)
latest_storage = _safe_float(latest.get("TWSO"), 0.0)
latest_moisture = _safe_float(latest.get("SM"), 0.0) * 100.0
summary = [
{
"title": "تعداد برگ تخمینی",
"subtitle": "وضعیت فعلی",
"amount": round(_estimate_leaf_count(latest_lai), 2),
"unit": "leaf",
"avatarColor": "success",
"avatarIcon": "tabler-leaf",
},
{
"title": "وزن بیوماس",
"subtitle": "برآورد فعلی",
"amount": round(latest_biomass, 2),
"unit": "kg/ha",
"avatarColor": "primary",
"avatarIcon": "tabler-chart-bar",
},
{
"title": "وزن محصول",
"subtitle": "برآورد فعلی",
"amount": round(latest_storage, 2),
"unit": "kg/ha",
"avatarColor": "warning",
"avatarIcon": "tabler-scale",
},
{
"title": "رطوبت خاک",
"subtitle": "آخرین روز",
"amount": round(latest_moisture, 2),
"unit": "%",
"avatarColor": "info",
"avatarIcon": "tabler-droplet",
},
]
return {
"farm_uuid": context.farm_uuid,
"plant_name": context.plant_name,
"engine": simulation_result.get("engine"),
"model_name": simulation_result.get("model_name"),
"scenario_id": scenario_id,
"simulation_warning": simulation_warning,
"categories": categories,
"series": [
{"name": "تعداد برگ تخمینی", "key": "leaf_count_estimate", "data": leaf_count_series},
{"name": "وزن بیوماس", "key": "biomass_weight", "data": biomass_series},
{"name": "وزن محصول", "key": "storage_organ_weight", "data": storage_weight_series},
{"name": "شاخص سطح برگ", "key": "lai", "data": lai_series},
{"name": "رطوبت خاک", "key": "soil_moisture_percent", "data": moisture_series},
],
"summary": summary,
"current_state": {
"date": latest.get("DAY"),
"leaf_count_estimate": round(_estimate_leaf_count(latest_lai), 2),
"leaf_area_index": round(latest_lai, 4),
"biomass_weight": round(latest_biomass, 2),
"storage_organ_weight": round(latest_storage, 2),
"soil_moisture_percent": round(latest_moisture, 2),
"development_stage": round(_safe_float(latest.get("DVS"), 0.0), 4),
"gdd": round(_safe_float(latest.get("GDD"), 0.0), 2),
},
"metrics": simulation_result.get("metrics") or {},
"daily_output": daily_output,
}
class CurrentFarmChartSimulator:
"""سازنده chart وضعیت فعلی مزرعه برای خروجی مستقل از dashboard."""
def simulate(self, *, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
if not farm_uuid:
raise GrowthSimulationError("farm_uuid is required.")
resolved_plant_name = plant_name
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.")
plant = sensor.plants.first()
if plant is None:
raise GrowthSimulationError("Plant not found for the selected farm.")
resolved_plant_name = plant.name
context = build_growth_context(
{
"farm_uuid": farm_uuid,
"plant_name": resolved_plant_name,
"dynamic_parameters": DEFAULT_DYNAMIC_PARAMETERS,
"page_size": DEFAULT_PAGE_SIZE,
}
)
simulation_result, scenario_id, simulation_warning = _run_simulation(context)
return _build_current_farm_chart_payload(
context,
simulation_result,
scenario_id,
simulation_warning,
)
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
from farm_data.models import SensorData
from plant.gdd import resolve_growth_profile
from .growth_simulation import (
DEFAULT_DYNAMIC_PARAMETERS,
DEFAULT_PAGE_SIZE,
GrowthSimulationError,
_run_simulation,
build_growth_context,
)
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
if value in (None, ""):
return default
return float(value)
except (TypeError, ValueError):
return default
def _harvest_description(
*,
plant_name: str,
current_gdd: float,
required_gdd: float,
remaining_gdd: float,
estimated_days: int,
maturity_reached_in_simulation: bool,
) -> str:
if maturity_reached_in_simulation:
return (
f"شبيه ساز رشد نشان مي دهد {plant_name} با روند فعلي در حدود {estimated_days} روز ديگر "
f"به بازه برداشت مي رسد. تا امروز {round(current_gdd, 1)} واحد-روز رشد ثبت شده و "
f"نياز باقيمانده تا بلوغ حدود {round(remaining_gdd, 1)} واحد-روز است."
)
return (
f"شبيه ساز تا انتهاي forecast هنوز به رسيدگي کامل نرسيده، اما با ميانگين رشد فعلي "
f"براورد مي شود {plant_name} حدود {estimated_days} روز ديگر به برداشت برسد. "
f"تا امروز {round(current_gdd, 1)} از {round(required_gdd, 1)} واحد-روز مورد نياز طي شده است."
)
def build_harvest_prediction_payload(*, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
resolved_plant_name = plant_name
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.")
plant = sensor.plants.first()
if plant is None:
raise GrowthSimulationError("Plant not found for the selected farm.")
resolved_plant_name = plant.name
context = build_growth_context(
{
"farm_uuid": farm_uuid,
"plant_name": resolved_plant_name,
"dynamic_parameters": DEFAULT_DYNAMIC_PARAMETERS,
"page_size": DEFAULT_PAGE_SIZE,
}
)
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.")
profile = resolve_growth_profile(context.plant)
required_gdd = _safe_float(profile.get("required_gdd_for_maturity"), 1200.0)
current_gdd = _safe_float(profile.get("current_cumulative_gdd"), 0.0)
cumulative_gdd = current_gdd
maturity_date = None
daily_gdd_forecast = []
for item in daily_output:
day_gdd = _safe_float(item.get("GDD"), 0.0)
cumulative_gdd += day_gdd
day_value = item.get("DAY")
iso_day = day_value.isoformat() if isinstance(day_value, date) else str(day_value)
daily_gdd_forecast.append(
{
"date": iso_day,
"gdd": round(day_gdd, 3),
"cumulative_gdd": round(cumulative_gdd, 3),
"development_stage": round(_safe_float(item.get("DVS"), 0.0), 4),
}
)
if _safe_float(item.get("DVS"), 0.0) >= 2.0 or cumulative_gdd >= required_gdd:
maturity_date = date.fromisoformat(iso_day)
break
maturity_reached_in_simulation = maturity_date is not None
if maturity_date is None:
last_day = date.fromisoformat(str(daily_output[-1].get("DAY")))
simulated_days = max(len(daily_output), 1)
avg_daily_gdd = max((cumulative_gdd - current_gdd) / simulated_days, 0.0)
remaining_after_simulation = max(required_gdd - cumulative_gdd, 0.0)
extra_days = 0
if avg_daily_gdd > 0 and remaining_after_simulation > 0:
extra_days = int(remaining_after_simulation / avg_daily_gdd)
if remaining_after_simulation % avg_daily_gdd:
extra_days += 1
maturity_date = last_day + timedelta(days=max(extra_days, 0))
remaining_gdd = max(required_gdd - current_gdd, 0.0)
days_until = max((maturity_date - date.today()).days, 0)
window_start = maturity_date - timedelta(days=3)
window_end = maturity_date + timedelta(days=3)
return {
"date": maturity_date.isoformat(),
"dateFormatted": f"{maturity_date.day} {maturity_date.strftime('%B')} {maturity_date.year}",
"daysUntil": days_until,
"description": _harvest_description(
plant_name=context.plant_name,
current_gdd=current_gdd,
required_gdd=required_gdd,
remaining_gdd=remaining_gdd,
estimated_days=days_until,
maturity_reached_in_simulation=maturity_reached_in_simulation,
),
"optimalWindowStart": window_start.isoformat(),
"optimalWindowEnd": window_end.isoformat(),
"gddDetails": {
"current_cumulative_gdd": round(current_gdd, 3),
"required_gdd_for_maturity": round(required_gdd, 3),
"remaining_gdd": round(remaining_gdd, 3),
"estimated_days_to_harvest": days_until,
"predicted_harvest_date": maturity_date.isoformat(),
"predicted_harvest_window": {
"start": window_start.isoformat(),
"end": window_end.isoformat(),
},
"daily_gdd_forecast": daily_gdd_forecast,
"simulation_engine": simulation_result.get("engine"),
"simulation_model_name": simulation_result.get("model_name"),
"simulation_warning": simulation_warning,
"scenario_id": scenario_id,
},
}
class HarvestPredictionService:
def get_harvest_prediction(self, *, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
return build_harvest_prediction_payload(farm_uuid=farm_uuid, plant_name=plant_name)
+55
View File
@@ -72,3 +72,58 @@ class GrowthSimulationResultSerializer(serializers.Serializer):
pagination = GrowthPaginationSerializer()
daily_records_count = serializers.IntegerField()
default_page_size = serializers.IntegerField()
class CurrentFarmChartRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(help_text="شناسه یکتای مزرعه")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
class CurrentFarmChartResponseSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(allow_null=True)
plant_name = serializers.CharField()
engine = serializers.CharField(allow_null=True)
model_name = serializers.CharField(allow_null=True)
scenario_id = serializers.IntegerField(allow_null=True)
simulation_warning = serializers.CharField(allow_null=True, allow_blank=True)
categories = serializers.ListField(child=serializers.CharField())
series = serializers.JSONField()
summary = serializers.JSONField()
current_state = serializers.JSONField()
metrics = serializers.JSONField()
daily_output = serializers.JSONField()
class HarvestPredictionRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(help_text="شناسه یکتای مزرعه")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
class HarvestPredictionResponseSerializer(serializers.Serializer):
date = serializers.CharField()
dateFormatted = serializers.CharField()
daysUntil = serializers.IntegerField()
description = serializers.CharField()
optimalWindowStart = serializers.CharField()
optimalWindowEnd = serializers.CharField()
gddDetails = serializers.JSONField()
class YieldPredictionRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(help_text="شناسه یکتای مزرعه")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
class YieldPredictionResponseSerializer(serializers.Serializer):
farm_uuid = serializers.CharField()
plant_name = serializers.CharField(allow_null=True)
predictedYieldTons = serializers.FloatField()
predictedYieldRaw = serializers.FloatField()
unit = serializers.CharField()
sourceUnit = serializers.CharField()
simulationEngine = serializers.CharField(allow_null=True)
simulationModel = serializers.CharField(allow_null=True)
scenarioId = serializers.IntegerField(allow_null=True)
simulationWarning = serializers.CharField(allow_null=True, allow_blank=True)
supportingMetrics = serializers.JSONField()
+155 -10
View File
@@ -54,16 +54,31 @@ class PlantGrowthSimulationApiTests(TestCase):
]
def test_run_growth_simulation_returns_stage_timeline(self):
result = run_growth_simulation(
{
"plant_name": self.plant.name,
"dynamic_parameters": ["DVS", "LAI", "TAGP"],
"weather": self.weather,
"soil_parameters": {"SMFCF": 0.34, "SMW": 0.14, "RDMSOL": 120.0},
"site_parameters": {"WAV": 40.0},
"page_size": 2,
}
)
with patch("crop_simulation.growth_simulation._run_simulation") as mock_run_simulation:
mock_run_simulation.return_value = (
{
"engine": "pcse",
"model_name": "wofost",
"metrics": {"yield_estimate": 10.0},
"daily_output": [
{"DAY": "2026-04-01", "DVS": 0.1, "LAI": 0.2, "TAGP": 10.0},
{"DAY": "2026-04-02", "DVS": 0.3, "LAI": 0.4, "TAGP": 20.0},
{"DAY": "2026-04-03", "DVS": 1.1, "LAI": 0.6, "TAGP": 30.0},
],
},
12,
None,
)
result = run_growth_simulation(
{
"plant_name": self.plant.name,
"dynamic_parameters": ["DVS", "LAI", "TAGP"],
"weather": self.weather,
"soil_parameters": {"SMFCF": 0.34, "SMW": 0.14, "RDMSOL": 120.0},
"site_parameters": {"WAV": 40.0},
"page_size": 2,
}
)
self.assertEqual(result["plant_name"], self.plant.name)
self.assertGreaterEqual(result["daily_records_count"], 3)
@@ -143,3 +158,133 @@ class PlantGrowthSimulationApiTests(TestCase):
self.assertEqual(payload["pagination"]["page"], 2)
self.assertEqual(len(payload["stages_page"]), 1)
self.assertEqual(payload["stages_page"][0]["stage_code"], "vegetative")
@patch("crop_simulation.views.apps.get_app_config")
def test_current_farm_chart_api_returns_simulation_payload(self, mock_get_app_config):
mock_simulator = SimpleNamespace(
simulate=lambda **_kwargs: {
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
"engine": "growth_projection",
"model_name": "growth_projection_v1",
"scenario_id": 12,
"simulation_warning": None,
"categories": ["2026-04-01", "2026-04-02"],
"series": [
{"name": "تعداد برگ تخمینی", "key": "leaf_count_estimate", "data": [120.0, 140.0]},
{"name": "وزن بیوماس", "key": "biomass_weight", "data": [35.0, 45.0]},
],
"summary": [
{
"title": "تعداد برگ تخمینی",
"subtitle": "وضعیت فعلی",
"amount": 140.0,
"unit": "leaf",
"avatarColor": "success",
"avatarIcon": "tabler-leaf",
}
],
"current_state": {
"date": "2026-04-02",
"leaf_count_estimate": 140.0,
"leaf_area_index": 0.0117,
"biomass_weight": 45.0,
"storage_organ_weight": 10.0,
"soil_moisture_percent": 41.2,
"development_stage": 0.35,
"gdd": 9.0,
},
"metrics": {"yield_estimate": 10.0},
"daily_output": [],
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_current_farm_chart_simulator=lambda: mock_simulator
)
response = self.client.post(
"/current-farm-chart/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["plant_name"], self.plant.name)
self.assertEqual(payload["scenario_id"], 12)
self.assertEqual(payload["current_state"]["leaf_count_estimate"], 140.0)
self.assertEqual(payload["series"][0]["key"], "leaf_count_estimate")
@patch("crop_simulation.views.apps.get_app_config")
def test_harvest_prediction_api_returns_payload(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_harvest_prediction=lambda **_kwargs: {
"date": "2026-05-14",
"dateFormatted": "14 May 2026",
"daysUntil": 43,
"description": "شبيه ساز نشان مي دهد حدود 43 روز ديگر تا برداشت باقي مانده است.",
"optimalWindowStart": "2026-05-11",
"optimalWindowEnd": "2026-05-17",
"gddDetails": {
"current_cumulative_gdd": 50.0,
"required_gdd_for_maturity": 1200.0,
"remaining_gdd": 1150.0,
"simulation_engine": "growth_projection",
},
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_harvest_prediction_service=lambda: mock_service
)
response = self.client.post(
"/harvest-prediction/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["daysUntil"], 43)
self.assertEqual(payload["gddDetails"]["simulation_engine"], "growth_projection")
@patch("crop_simulation.views.apps.get_app_config")
def test_yield_prediction_api_returns_payload(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_yield_prediction=lambda **_kwargs: {
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
"predictedYieldTons": 5.4,
"predictedYieldRaw": 5400.0,
"unit": "تن",
"sourceUnit": "kg/ha",
"simulationEngine": "growth_projection",
"simulationModel": "growth_projection_v1",
"scenarioId": 12,
"simulationWarning": None,
"supportingMetrics": {"yield_estimate": 5400.0},
}
)
mock_get_app_config.return_value = SimpleNamespace(
get_yield_prediction_service=lambda: mock_service
)
response = self.client.post(
"/yield-prediction/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["predictedYieldTons"], 5.4)
self.assertEqual(payload["sourceUnit"], "kg/ha")
+10 -1
View File
@@ -1,9 +1,18 @@
from django.urls import path
from .views import PlantGrowthSimulationStatusView, PlantGrowthSimulationView
from .views import (
CurrentFarmSimulationChartView,
HarvestPredictionView,
PlantGrowthSimulationStatusView,
PlantGrowthSimulationView,
YieldPredictionView,
)
urlpatterns = [
path("current-farm-chart/", CurrentFarmSimulationChartView.as_view(), name="current-farm-chart"),
path("harvest-prediction/", HarvestPredictionView.as_view(), name="harvest-prediction"),
path("yield-prediction/", YieldPredictionView.as_view(), name="yield-prediction"),
path("growth/", PlantGrowthSimulationView.as_view(), name="growth-simulation"),
path(
"growth/<str:task_id>/status/",
+177 -1
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from django.apps import apps
from drf_spectacular.utils import OpenApiExample, extend_schema
from rest_framework import status
from rest_framework.response import Response
@@ -13,9 +15,15 @@ from config.openapi import (
from .growth_simulation import MAX_PAGE_SIZE, paginate_growth_stages
from .serializers import (
CurrentFarmChartRequestSerializer,
CurrentFarmChartResponseSerializer,
GrowthSimulationQueuedSerializer,
GrowthSimulationRequestSerializer,
GrowthSimulationResultSerializer,
HarvestPredictionRequestSerializer,
HarvestPredictionResponseSerializer,
YieldPredictionRequestSerializer,
YieldPredictionResponseSerializer,
)
from .tasks import run_growth_simulation_task
@@ -99,7 +107,7 @@ class PlantGrowthSimulationView(APIView):
value={
"plant_name": "گوجه‌فرنگی",
"dynamic_parameters": ["DVS", "LAI", "TAGP"],
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"farm_uuid": "11111111-1111-1111-1111-111111111111",
},
request_only=True,
),
@@ -173,3 +181,171 @@ class PlantGrowthSimulationStatusView(APIView):
{"code": 200, "msg": "success", "data": payload},
status=status.HTTP_200_OK,
)
CurrentFarmChartEnvelopeSerializer = build_envelope_serializer(
"CurrentFarmChartEnvelopeSerializer",
CurrentFarmChartResponseSerializer,
)
HarvestPredictionEnvelopeSerializer = build_envelope_serializer(
"HarvestPredictionEnvelopeSerializer",
HarvestPredictionResponseSerializer,
)
YieldPredictionEnvelopeSerializer = build_envelope_serializer(
"YieldPredictionEnvelopeSerializer",
YieldPredictionResponseSerializer,
)
class CurrentFarmSimulationChartView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="chart شبیه سازی وضعیت فعلی مزرعه",
description=(
"با دریافت farm_uuid، یک شبیه سازی از وضعیت فعلی مزرعه اجرا می کند و داده chart شامل برگ، وزن، بیوماس، رطوبت و خروجی روزانه را برمی گرداند."
),
request=CurrentFarmChartRequestSerializer,
responses={
200: build_response(
CurrentFarmChartEnvelopeSerializer,
"خروجی chart شبیه سازی وضعیت فعلی مزرعه.",
),
400: build_response(
GrowthSimulationErrorSerializer,
"داده ورودی نامعتبر است.",
),
500: build_response(
GrowthSimulationErrorSerializer,
"خطا در اجرای chart شبیه سازی مزرعه.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست chart",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
},
request_only=True,
),
],
)
def post(self, request):
serializer = CurrentFarmChartRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
simulator = apps.get_app_config("crop_simulation").get_current_farm_chart_simulator()
try:
result = simulator.simulate(**serializer.validated_data)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در اجرای chart شبیه سازی مزرعه: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(
{"code": 200, "msg": "success", "data": result},
status=status.HTTP_200_OK,
)
class HarvestPredictionView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="پیش بینی زمان تقریبی برداشت",
description=(
"با دریافت farm_uuid، از شبیه ساز رشد برای برآورد زمان باقی مانده تا برداشت استفاده می کند "
"و تاریخ تقریبی برداشت را برمی گرداند."
),
request=HarvestPredictionRequestSerializer,
responses={
200: build_response(
HarvestPredictionEnvelopeSerializer,
"خروجی پیش بینی زمان برداشت مزرعه.",
),
400: build_response(
GrowthSimulationErrorSerializer,
"داده ورودی نامعتبر است.",
),
500: build_response(
GrowthSimulationErrorSerializer,
"خطا در پیش بینی زمان برداشت.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست harvest prediction",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
},
request_only=True,
),
],
)
def post(self, request):
serializer = HarvestPredictionRequestSerializer(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("crop_simulation").get_harvest_prediction_service()
try:
result = service.get_harvest_prediction(**serializer.validated_data)
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": result},
status=status.HTTP_200_OK,
)
class YieldPredictionView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="پیش بینی عملکرد مزرعه",
description="با دریافت farm_uuid، خروجی شبیه ساز رشد را به برآورد عملکرد قابل استفاده در KPI تبدیل می کند.",
request=YieldPredictionRequestSerializer,
responses={
200: build_response(YieldPredictionEnvelopeSerializer, "خروجی پیش بینی عملکرد مزرعه."),
400: build_response(GrowthSimulationErrorSerializer, "داده ورودی نامعتبر است."),
500: build_response(GrowthSimulationErrorSerializer, "خطا در پیش بینی عملکرد."),
},
examples=[
OpenApiExample(
"نمونه درخواست yield prediction",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
},
request_only=True,
),
],
)
def post(self, request):
serializer = YieldPredictionRequestSerializer(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("crop_simulation").get_yield_prediction_service()
try:
result = service.get_yield_prediction(**serializer.validated_data)
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": result}, status=status.HTTP_200_OK)
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
from statistics import mean
from typing import Any
from farm_data.models import SensorData
from .growth_simulation import GrowthSimulationError, _run_simulation, _safe_float, build_growth_context
def _clamp(value: float, lower: float, upper: float) -> float:
return max(lower, min(upper, value))
def _level_for_index(water_stress: int) -> str:
if water_stress <= 20:
return "پایین"
if water_stress <= 45:
return "متوسط"
return "بالا"
def _stage_sensitivity(dvs: float) -> tuple[str, float]:
if dvs < 0.2:
return "establishment", 0.9
if dvs < 1.0:
return "vegetative", 1.0
if dvs < 1.3:
return "flowering", 1.2
if dvs < 2.0:
return "reproductive", 1.1
return "maturity", 0.85
def _compute_water_stress_index(
*,
daily_output: list[dict[str, Any]],
soil_parameters: dict[str, Any],
) -> tuple[int, dict[str, Any]]:
latest = daily_output[-1] if daily_output else {}
recent_window = daily_output[-3:] if daily_output else []
smfcf = _safe_float(soil_parameters.get("SMFCF"), 0.34)
smw = _safe_float(soil_parameters.get("SMW"), 0.14)
rdmsol = max(_safe_float(soil_parameters.get("RDMSOL"), 120.0), 1.0)
latest_sm = _safe_float(latest.get("SM"), 0.0)
available_water_ratio = _clamp((latest_sm - smw) / max(smfcf - smw, 0.01), 0.0, 1.0)
moisture_deficit = (1.0 - available_water_ratio) * 65.0
recent_et0 = mean(_safe_float(item.get("ET0"), 0.0) for item in recent_window) if recent_window else 0.0
et0_pressure = _clamp((recent_et0 / 0.45) * 18.0, 0.0, 18.0)
recent_rain = sum(_safe_float(item.get("RAIN"), 0.0) for item in recent_window)
rainfall_relief = _clamp(recent_rain * 2.5, 0.0, 15.0)
moisture_trend = 0.0
if len(recent_window) >= 2:
moisture_trend = max(
(_safe_float(recent_window[0].get("SM"), latest_sm) - latest_sm) * 100.0,
0.0,
)
trend_pressure = _clamp(moisture_trend * 1.6, 0.0, 12.0)
stage_code, stage_multiplier = _stage_sensitivity(_safe_float(latest.get("DVS"), 0.0))
root_depth_relief = _clamp(((rdmsol - 60.0) / 60.0) * 6.0, 0.0, 6.0)
raw_score = ((moisture_deficit + et0_pressure + trend_pressure - rainfall_relief - root_depth_relief) *
stage_multiplier)
water_stress = int(round(_clamp(raw_score, 0.0, 100.0)))
return water_stress, {
"soilMoisturePercent": round(latest_sm * 100.0, 2),
"availableWaterRatio": round(available_water_ratio, 4),
"fieldCapacity": round(smfcf, 4),
"wiltingPoint": round(smw, 4),
"rootDepthCm": round(rdmsol, 2),
"recentEt0": round(recent_et0, 4),
"recentRain": round(recent_rain, 2),
"soilMoistureDrop": round(moisture_trend, 2),
"developmentStage": round(_safe_float(latest.get("DVS"), 0.0), 4),
"stageCode": stage_code,
"stageSensitivity": round(stage_multiplier, 2),
"engine": "crop_simulation",
"formula": (
"stress = clamp(((moisture_deficit + et0_pressure + trend_pressure - "
"rainfall_relief - root_depth_relief) * stage_sensitivity), 0, 100)"
),
}
class WaterStressSimulationService:
def _resolve_plant_name(self, *, farm_uuid: str, plant_name: str | None) -> str:
if plant_name:
return plant_name
sensor = SensorData.objects.prefetch_related("plants").filter(farm_uuid=farm_uuid).first()
if sensor is None:
raise GrowthSimulationError("Farm not found.")
plant = sensor.plants.first()
if plant is None:
raise GrowthSimulationError("Plant not found for the selected farm.")
return plant.name
def get_water_stress(self, *, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
resolved_plant_name = self._resolve_plant_name(farm_uuid=farm_uuid, plant_name=plant_name)
context = build_growth_context(
{
"farm_uuid": farm_uuid,
"plant_name": resolved_plant_name,
}
)
simulation_result, _scenario_id, simulation_warning = _run_simulation(context)
daily_output = simulation_result.get("daily_output") or []
if not daily_output:
raise GrowthSimulationError("Water stress simulation produced no daily output.")
water_stress, source_metric = _compute_water_stress_index(
daily_output=daily_output,
soil_parameters=context.soil_parameters,
)
if simulation_warning:
source_metric["simulationWarning"] = simulation_warning
return {
"farm_uuid": str(farm_uuid),
"plant_name": context.plant_name,
"waterStressIndex": water_stress,
"level": _level_for_index(water_stress),
"sourceMetric": source_metric,
}
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any
from .growth_simulation import CurrentFarmChartSimulator, GrowthSimulationError
def build_yield_prediction_payload(*, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
simulator = CurrentFarmChartSimulator()
result = simulator.simulate(farm_uuid=farm_uuid, plant_name=plant_name)
yield_estimate = float((result.get("metrics") or {}).get("yield_estimate") or 0.0)
predicted_yield_tons = round(max(yield_estimate / 1000.0, 0.0), 2)
return {
"farm_uuid": farm_uuid,
"plant_name": result.get("plant_name"),
"predictedYieldTons": predicted_yield_tons,
"predictedYieldRaw": round(yield_estimate, 2),
"unit": "تن",
"sourceUnit": "kg/ha",
"simulationEngine": result.get("engine"),
"simulationModel": result.get("model_name"),
"scenarioId": result.get("scenario_id"),
"simulationWarning": result.get("simulation_warning"),
"supportingMetrics": result.get("metrics") or {},
}
class YieldPredictionService:
def get_yield_prediction(self, *, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
try:
return build_yield_prediction_payload(farm_uuid=farm_uuid, plant_name=plant_name)
except GrowthSimulationError:
raise