This commit is contained in:
2026-05-05 21:02:12 +03:30
parent 5301071df5
commit 1679825ae2
47 changed files with 1347 additions and 1403 deletions
+3 -6
View File
@@ -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
+4 -4
View File
@@ -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
+9 -13
View File
@@ -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"))
+57 -1
View File
@@ -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(
+82 -6
View File
@@ -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,
)
+4 -4
View File
@@ -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 -3
View File
@@ -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: