UPDATE
This commit is contained in:
@@ -10,6 +10,7 @@ import logging
|
||||
from django.core.paginator import EmptyPage, Paginator
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm
|
||||
from plant.gdd import calculate_daily_gdd, resolve_growth_profile
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
@@ -775,14 +776,10 @@ class CurrentFarmChartSimulator:
|
||||
|
||||
resolved_plant_name = plant_name
|
||||
if not resolved_plant_name:
|
||||
sensor = (
|
||||
SensorData.objects.prefetch_related("plants")
|
||||
.filter(farm_uuid=farm_uuid)
|
||||
.first()
|
||||
)
|
||||
sensor = get_canonical_farm_record(farm_uuid)
|
||||
if sensor is None:
|
||||
raise GrowthSimulationError("مزرعه پیدا نشد.")
|
||||
plant = sensor.plants.first()
|
||||
plant = get_runtime_plant_for_farm(sensor)
|
||||
if plant is None:
|
||||
raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.")
|
||||
resolved_plant_name = plant.name
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm
|
||||
from plant.gdd import resolve_growth_profile
|
||||
|
||||
from .growth_simulation import (
|
||||
@@ -55,10 +55,10 @@ def build_harvest_prediction_payload(
|
||||
) -> 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:
|
||||
farm = get_canonical_farm_record(farm_uuid)
|
||||
if farm is None:
|
||||
raise GrowthSimulationError("مزرعه پیدا نشد.")
|
||||
plant = sensor.plants.first()
|
||||
plant = get_runtime_plant_for_farm(farm)
|
||||
if plant is None:
|
||||
raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.")
|
||||
resolved_plant_name = plant.name
|
||||
|
||||
@@ -445,23 +445,18 @@ def build_simulation_payload_from_farm(
|
||||
agromanagement: Any | None = None,
|
||||
site_parameters: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import (
|
||||
get_canonical_farm_record,
|
||||
get_runtime_plant_for_farm,
|
||||
list_runtime_plants_for_farm,
|
||||
)
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
farm = (
|
||||
SensorData.objects.select_related("center_location", "irrigation_method")
|
||||
.prefetch_related("plants", "center_location__depths")
|
||||
.filter(farm_uuid=farm_uuid)
|
||||
.first()
|
||||
)
|
||||
farm = get_canonical_farm_record(farm_uuid)
|
||||
if farm is None:
|
||||
raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.")
|
||||
|
||||
plant = None
|
||||
if plant_name:
|
||||
plant = farm.plants.filter(name=plant_name).first()
|
||||
if plant is None:
|
||||
plant = farm.plants.first()
|
||||
plant = get_runtime_plant_for_farm(farm, plant_name=plant_name)
|
||||
|
||||
if weather is not None:
|
||||
resolved_weather = _normalize_weather_records(weather)
|
||||
@@ -569,6 +564,7 @@ def build_simulation_payload_from_farm(
|
||||
|
||||
return {
|
||||
"farm": farm,
|
||||
"runtime_plants": list_runtime_plants_for_farm(farm),
|
||||
"plant": plant,
|
||||
"weather": resolved_weather,
|
||||
"soil": resolved_soil,
|
||||
@@ -1052,7 +1048,7 @@ class CropSimulationService:
|
||||
if not crops and farm_uuid:
|
||||
base = build_simulation_payload_from_farm(farm_uuid=str(farm_uuid))
|
||||
crops = []
|
||||
for plant in base["farm"].plants.all():
|
||||
for plant in base["runtime_plants"]:
|
||||
simulation_profile = _extract_plant_simulation_profile(plant)
|
||||
crop_payload = (
|
||||
deepcopy(simulation_profile.get("crop_parameters"))
|
||||
|
||||
@@ -8,9 +8,11 @@ from unittest.mock import patch
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from .models import SimulationRun, SimulationScenario
|
||||
from .services import CropSimulationService, CropSimulationError, PcseSimulationManager
|
||||
from .views import PlantGrowthSimulationView
|
||||
|
||||
|
||||
def build_weather(days: int = 5) -> list[dict]:
|
||||
@@ -108,7 +110,61 @@ class CropSimulationServiceTests(TestCase):
|
||||
crop_parameters=self.crop,
|
||||
strategies=[{"label": "only", "agromanagement": build_agromanagement()}],
|
||||
site_parameters=self.site,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CropSimulationViewContractTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
@patch("crop_simulation.views.run_growth_simulation_task.delay")
|
||||
def test_growth_queue_response_includes_live_ai_metadata(self, mock_delay):
|
||||
mock_delay.return_value.id = "task-123"
|
||||
request = self.factory.post(
|
||||
"/api/crop-simulation/growth/",
|
||||
{
|
||||
"plant_name": "wheat",
|
||||
"dynamic_parameters": ["DVS"],
|
||||
"weather": [
|
||||
{
|
||||
"DAY": "2026-04-01",
|
||||
"LAT": 35.7,
|
||||
"LON": 51.4,
|
||||
"TMIN": 12,
|
||||
"TMAX": 24,
|
||||
"RAIN": 0.0,
|
||||
"ET0": 0.32,
|
||||
}
|
||||
],
|
||||
"soil_parameters": {"SMFCF": 0.34, "SMW": 0.14, "RDMSOL": 120.0},
|
||||
"site_parameters": {"WAV": 40.0},
|
||||
"agromanagement": [
|
||||
{
|
||||
"2026-04-01": {
|
||||
"CropCalendar": {
|
||||
"crop_name": "wheat",
|
||||
"variety_name": "winter-wheat",
|
||||
"crop_start_date": "2026-04-05",
|
||||
"crop_start_type": "sowing",
|
||||
"crop_end_date": "2026-09-01",
|
||||
"crop_end_type": "harvest",
|
||||
"max_duration": 180,
|
||||
},
|
||||
"TimedEvents": [],
|
||||
"StateEvents": [],
|
||||
}
|
||||
},
|
||||
{},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
response = PlantGrowthSimulationView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertEqual(response.data["meta"]["flow_type"], "live_ai_inference")
|
||||
self.assertEqual(response.data["meta"]["source_service"], "ai_crop_simulation")
|
||||
|
||||
def test_recommend_best_crop_returns_best_candidate(self):
|
||||
with patch.object(
|
||||
|
||||
@@ -7,6 +7,7 @@ from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from config.integration_contract import build_integration_meta
|
||||
from config.openapi import (
|
||||
build_envelope_serializer,
|
||||
build_response,
|
||||
@@ -151,6 +152,14 @@ class PlantGrowthSimulationView(APIView):
|
||||
"status_url": f"/api/crop-simulation/growth/{task.id}/status/",
|
||||
"plant_name": serializer.validated_data["plant_name"],
|
||||
},
|
||||
"meta": build_integration_meta(
|
||||
flow_type="live_ai_inference",
|
||||
source_type="provider",
|
||||
source_service="ai_crop_simulation",
|
||||
ownership="ai",
|
||||
live=True,
|
||||
cached=False,
|
||||
),
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
@@ -202,7 +211,19 @@ class PlantGrowthSimulationStatusView(APIView):
|
||||
payload["error"] = str(result.result)
|
||||
|
||||
return Response(
|
||||
{"code": 200, "msg": "موفق", "data": payload},
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "موفق",
|
||||
"data": payload,
|
||||
"meta": build_integration_meta(
|
||||
flow_type="live_ai_inference",
|
||||
source_type="provider",
|
||||
source_service="ai_crop_simulation",
|
||||
ownership="ai",
|
||||
live=result.state in {"PENDING", "PROGRESS", "SUCCESS"},
|
||||
cached=False,
|
||||
),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -281,7 +302,19 @@ class CurrentFarmSimulationChartView(APIView):
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"code": 200, "msg": "موفق", "data": result},
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "موفق",
|
||||
"data": result,
|
||||
"meta": build_integration_meta(
|
||||
flow_type="ai_owned_derived_output",
|
||||
source_type="provider",
|
||||
source_service="ai_crop_simulation_chart",
|
||||
ownership="ai",
|
||||
live=True,
|
||||
cached=False,
|
||||
),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -342,7 +375,19 @@ class HarvestPredictionView(APIView):
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"code": 200, "msg": "موفق", "data": result},
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "موفق",
|
||||
"data": result,
|
||||
"meta": build_integration_meta(
|
||||
flow_type="ai_owned_derived_output",
|
||||
source_type="provider",
|
||||
source_service="ai_crop_simulation_harvest_prediction",
|
||||
ownership="ai",
|
||||
live=True,
|
||||
cached=False,
|
||||
),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -388,7 +433,22 @@ class YieldPredictionView(APIView):
|
||||
{"code": 500, "msg": f"خطا در پیش بینی عملکرد: {exc}", "data": None},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
return Response({"code": 200, "msg": "موفق", "data": result}, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "موفق",
|
||||
"data": result,
|
||||
"meta": build_integration_meta(
|
||||
flow_type="ai_owned_derived_output",
|
||||
source_type="provider",
|
||||
source_service="ai_crop_simulation_yield_prediction",
|
||||
ownership="ai",
|
||||
live=True,
|
||||
cached=False,
|
||||
),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class YieldHarvestSummaryView(APIView):
|
||||
@@ -397,7 +457,7 @@ class YieldHarvestSummaryView(APIView):
|
||||
summary="خلاصه عملکرد و برداشت",
|
||||
description=(
|
||||
"خروجی داشبورد Yield & Harvest Summary را با اتکا به داده های قطعی شبیه سازی برمی گرداند. "
|
||||
"فعلا پاسخ به صورت mock با کارت های خالی بازگردانده می شود."
|
||||
"این endpoint خروجی derived واقعی تولید می کند و پاسخ آن mock نیست."
|
||||
),
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
@@ -492,4 +552,20 @@ class YieldHarvestSummaryView(APIView):
|
||||
irrigation_recommendation=validated.get("irrigation_recommendation"),
|
||||
fertilization_recommendation=validated.get("fertilization_recommendation"),
|
||||
)
|
||||
return Response({"code": 200, "msg": "موفق", "data": payload}, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "موفق",
|
||||
"data": payload,
|
||||
"meta": build_integration_meta(
|
||||
flow_type="ai_owned_derived_output",
|
||||
source_type="provider",
|
||||
source_service="ai_crop_simulation_yield_harvest_summary",
|
||||
ownership="ai",
|
||||
live=True,
|
||||
cached=False,
|
||||
),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from statistics import mean
|
||||
from typing import Any
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm
|
||||
|
||||
from .growth_simulation import GrowthSimulationError, _run_simulation, _safe_float, build_growth_context
|
||||
|
||||
@@ -94,11 +94,11 @@ class WaterStressSimulationService:
|
||||
if plant_name:
|
||||
return plant_name
|
||||
|
||||
sensor = SensorData.objects.prefetch_related("plants").filter(farm_uuid=farm_uuid).first()
|
||||
if sensor is None:
|
||||
farm = get_canonical_farm_record(farm_uuid)
|
||||
if farm is None:
|
||||
raise GrowthSimulationError("Farm not found.")
|
||||
|
||||
plant = sensor.plants.first()
|
||||
plant = get_runtime_plant_for_farm(farm)
|
||||
if plant is None:
|
||||
raise GrowthSimulationError("Plant not found for the selected farm.")
|
||||
return plant.name
|
||||
|
||||
@@ -13,6 +13,7 @@ from farm_data.models import SensorData
|
||||
from farm_data.services import get_farm_details
|
||||
from location_data.models import NdviObservation, SoilLocation
|
||||
|
||||
from rag.failure_contract import RAGServiceError
|
||||
from rag.services.yield_harvest import YieldHarvestRAGService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -119,13 +120,17 @@ class YieldHarvestSummaryService:
|
||||
try:
|
||||
rag_service = YieldHarvestRAGService()
|
||||
narrative_data = rag_service.generate_narrative(context_payload)
|
||||
except Exception as exc:
|
||||
except RAGServiceError as exc:
|
||||
logger.warning(
|
||||
"Yield harvest narrative generation failed for farm_uuid=%s: %s",
|
||||
farm_uuid,
|
||||
exc,
|
||||
)
|
||||
narrative_data = {}
|
||||
narrative_data = {
|
||||
"status": "error",
|
||||
"source": "llm",
|
||||
"narrative_error": exc.to_dict(),
|
||||
}
|
||||
return self._merge_narrative(deterministic_payload, narrative_data)
|
||||
|
||||
def _build_yield_prediction(
|
||||
@@ -703,7 +708,7 @@ class YieldHarvestSummaryService:
|
||||
) -> dict[str, Any]:
|
||||
farm = (
|
||||
SensorData.objects.select_related("center_location", "weather_forecast")
|
||||
.prefetch_related("center_location__depths", "plants")
|
||||
.prefetch_related("center_location__depths", "plant_assignments__plant")
|
||||
.filter(farm_uuid=farm_uuid)
|
||||
.first()
|
||||
)
|
||||
@@ -949,6 +954,11 @@ class YieldHarvestSummaryService:
|
||||
fallback_note,
|
||||
)
|
||||
|
||||
merged["narrative_status"] = narratives.get("status", "success")
|
||||
merged["narrative_source"] = narratives.get("source", "deterministic")
|
||||
if isinstance(narratives.get("narrative_error"), dict):
|
||||
merged["narrative_error"] = narratives["narrative_error"]
|
||||
|
||||
return merged
|
||||
|
||||
def _coalesce_text(self, *values: Any) -> str:
|
||||
|
||||
Reference in New Issue
Block a user