UPDATE
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user