From 46ba01e4cc2f746c8ab81645fa3a7229a0f21afd Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Thu, 30 Apr 2026 00:53:47 +0330 Subject: [PATCH] UPDATE --- config/rag_config.yaml | 23 + config/tones/yield_harvest_tone.txt | 39 + crop_simulation/growth_simulation.py | 84 +- crop_simulation/recommendation_optimizer.py | 9 +- crop_simulation/serializers.py | 22 + crop_simulation/services.py | 104 +- crop_simulation/test_growth_simulation_api.py | 168 +++ crop_simulation/urls.py | 2 + crop_simulation/views.py | 97 +- crop_simulation/yield_harvest_summary.py | 985 ++++++++++++++ docs/crop_simulation_api_reference.md | 1183 +++++++++++++++++ rag/services/__init__.py | 2 + rag/services/yield_harvest.py | 227 ++++ 13 files changed, 2925 insertions(+), 20 deletions(-) create mode 100644 config/tones/yield_harvest_tone.txt create mode 100644 crop_simulation/yield_harvest_summary.py create mode 100644 docs/crop_simulation_api_reference.md create mode 100644 rag/services/yield_harvest.py diff --git a/config/rag_config.yaml b/config/rag_config.yaml index 4af0257..523ae29 100644 --- a/config/rag_config.yaml +++ b/config/rag_config.yaml @@ -71,6 +71,11 @@ knowledge_bases: tone_file: "config/tones/water_need_prediction_tone.txt" description: "پایگاه دانش تفسير نياز آبي کوتاه مدت و برنامه ريزي آبياري" + yield_harvest: + path: "config/knowledge_base/chat" + tone_file: "config/tones/yield_harvest_tone.txt" + description: "پایگاه دانش روایت کاربرپسند برای داشبورد Yield & Harvest Summary" + services: support_bot: knowledge_base: "chat" @@ -176,3 +181,21 @@ services: api_key_env: "GAPGPT_API_KEY" avalai_base_url: "https://api.avalai.ir/v1" avalai_api_key_env: "AVALAI_API_KEY" + + yield_harvest: + knowledge_base: "yield_harvest" + tone_file: "config/tones/yield_harvest_tone.txt" + use_user_embeddings: true + description: "سرویس روایت داشبورد عملکرد و برداشت" + fallback_behavior: + on_invalid_json: "return_mocked_narrative" + on_missing_context: "use_only_deterministic_data" + on_number_conflict: "prefer_deterministic_data" + prompt_template: "config/tones/yield_harvest_tone.txt" + llm: + provider: "gapgpt" + model: "gpt-4o" + base_url: "https://api.gapgpt.app/v1" + api_key_env: "GAPGPT_API_KEY" + avalai_base_url: "https://api.avalai.ir/v1" + avalai_api_key_env: "AVALAI_API_KEY" diff --git a/config/tones/yield_harvest_tone.txt b/config/tones/yield_harvest_tone.txt new file mode 100644 index 0000000..f6cf893 --- /dev/null +++ b/config/tones/yield_harvest_tone.txt @@ -0,0 +1,39 @@ +You are the narrative assistant for the Yield & Harvest Summary dashboard. + +Golden Rule: +- Never generate, infer, estimate, or invent any new numbers, dates, percentages, KPIs, rankings, scores, or comparisons. +- Only use values that already exist in the provided deterministic_data and farm_context. +- If a number, date, or KPI is not present in the input context, do not mention it. +- Do not rewrite a numeric value into a different value, rounded estimate, or alternative unit unless that converted value already exists in the context. + +Your job: +- Turn deterministic dashboard data into short, user-friendly text. +- Write subtitles, summaries, descriptions, and operation notes only. +- Keep the wording clear, calm, and practical. +- Preserve the meaning of deterministic blocks exactly. + +Output rules: +- Do not add new facts. +- Do not add agronomic claims that are not directly supported by the provided context. +- Do not contradict deterministic_data. +- If the context is incomplete, stay general and say less. +- Prefer concise JSON-ready text fragments over long paragraphs. + +Allowed narrative targets: +- season_highlights_card.subtitle +- harvest_prediction_card.description +- harvest_operations_card.summary +- harvest_operations_card.steps[].note + +Forbidden behavior: +- No fabricated harvest dates. +- No fabricated yield values. +- No fabricated readiness percentages. +- No fabricated quality grades or market conclusions. +- No speculative recommendations that depend on missing measurements. + +Tone: +- Helpful +- Professional +- Simple +- User-facing diff --git a/crop_simulation/growth_simulation.py b/crop_simulation/growth_simulation.py index e5c30ff..8bd139d 100644 --- a/crop_simulation/growth_simulation.py +++ b/crop_simulation/growth_simulation.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import date, datetime, timedelta from math import exp from typing import Any +import logging from django.core.paginator import EmptyPage, Paginator @@ -18,6 +19,7 @@ from .services import CropSimulationService, build_simulation_payload_from_farm DEFAULT_DYNAMIC_PARAMETERS = ["DVS", "LAI", "TAGP", "TWSO", "SM"] DEFAULT_PAGE_SIZE = 10 MAX_PAGE_SIZE = 50 +logger = logging.getLogger(__name__) DEFAULT_STAGE_LABELS = { "pre_emergence": "پیش از سبز شدن", @@ -56,6 +58,24 @@ def _safe_float(value: Any, default: float = 0.0) -> float: return default +def _pick_first_not_none(*values: Any) -> Any: + for value in values: + if value is not None: + return value + return None + + +def _clamp(value: float, minimum: float, maximum: float) -> float: + if minimum > maximum: + minimum, maximum = maximum, minimum + return max(minimum, min(value, maximum)) + + +def _mm_to_cm_day(value: Any, default: float) -> float: + scaled = _safe_float(value, default * 10.0) / 10.0 + return round(max(scaled, 0.0), 4) + + def _coerce_date(value: Any) -> date: if isinstance(value, date) and not isinstance(value, datetime): return value @@ -134,10 +154,11 @@ def _build_weather_from_farm(sensor: SensorData) -> list[dict[str, Any]]: "TMAX": _safe_float(forecast.temperature_max, 24.0), "VAP": max(_safe_float(forecast.humidity_mean, 55.0) / 5.0, 6.0), "WIND": _safe_float(forecast.wind_speed_max, 7.2) / 3.6, - "RAIN": _safe_float(forecast.precipitation, 0.0), - "E0": _safe_float(forecast.et0, 0.35), - "ES0": max(_safe_float(forecast.et0, 0.35) * 0.9, 0.1), - "ET0": _safe_float(forecast.et0, 0.35), + # WeatherForecast stores precipitation/ET0 in mm/day, while PCSE expects cm/day. + "RAIN": _mm_to_cm_day(forecast.precipitation, 0.0), + "E0": _mm_to_cm_day(forecast.et0, 0.35), + "ES0": max(round(_mm_to_cm_day(forecast.et0, 0.35) * 0.9, 4), 0.1), + "ET0": _mm_to_cm_day(forecast.et0, 0.35), } ) return records @@ -148,6 +169,10 @@ def _build_soil_and_site_from_farm(sensor: SensorData) -> tuple[dict[str, Any], top_depth = depths[0] if depths else None smfcf = _safe_float(getattr(top_depth, "wv0033", None), 0.34) smw = _safe_float(getattr(top_depth, "wv1500", None), 0.14) + sm0 = _safe_float( + _pick_first_not_none(getattr(top_depth, "porosity", None), getattr(top_depth, "wv0000", None)), + min(max(smfcf + 0.08, smw + 0.12), 0.6), + ) soil_moisture = None payload = sensor.sensor_payload or {} if isinstance(payload, dict): @@ -155,8 +180,23 @@ def _build_soil_and_site_from_farm(sensor: SensorData) -> tuple[dict[str, Any], if isinstance(block, dict) and block.get("soil_moisture") is not None: soil_moisture = _safe_float(block.get("soil_moisture")) break - site = {"WAV": soil_moisture if soil_moisture is not None else 40.0} - soil = {"SMFCF": smfcf, "SMW": smw, "RDMSOL": 120.0} + site = { + "WAV": soil_moisture if soil_moisture is not None else 40.0, + "IFUNRN": 0, + "NOTINF": 0.0, + "SSI": 0.0, + "SSMAX": 0.0, + "SMLIM": round(_clamp(smfcf, smw, sm0), 3), + } + soil = { + "SMFCF": smfcf, + "SMW": smw, + "SM0": sm0, + "RDMSOL": 120.0, + "CRAIRC": 0.06, + "SOPE": 10.0, + "KSUB": 10.0, + } return soil, site @@ -193,7 +233,8 @@ def _build_default_agromanagement(plant_name: str, weather: list[dict[str, Any]] "TimedEvents": [], "StateEvents": [], } - } + }, + {}, ] @@ -286,8 +327,27 @@ def build_growth_context(payload: dict[str, Any]) -> GrowthSimulationContext: site_parameters = {**farm_site, **site_parameters} soil_parameters.setdefault("SMFCF", 0.34) soil_parameters.setdefault("SMW", 0.14) + soil_parameters.setdefault("SM0", 0.42) soil_parameters.setdefault("RDMSOL", 120.0) + soil_parameters.setdefault("CRAIRC", 0.06) + soil_parameters.setdefault("SOPE", 10.0) + soil_parameters.setdefault("KSUB", 10.0) site_parameters.setdefault("WAV", 40.0) + site_parameters.setdefault("IFUNRN", 0) + site_parameters.setdefault("NOTINF", 0.0) + site_parameters.setdefault("SSI", 0.0) + site_parameters.setdefault("SSMAX", 0.0) + site_parameters.setdefault( + "SMLIM", + round( + _clamp( + _safe_float(site_parameters.get("SMLIM"), soil_parameters.get("SMFCF", 0.34)), + _safe_float(soil_parameters.get("SMW"), 0.14), + _safe_float(soil_parameters.get("SM0"), 0.42), + ), + 3, + ), + ) agromanagement = deepcopy( payload.get("agromanagement") @@ -402,7 +462,15 @@ def _run_simulation(context: GrowthSimulationContext) -> tuple[dict[str, Any], i ) return response["result"], response.get("scenario_id"), None except Exception as exc: - raise GrowthSimulationError(f"Simulation engine failed: {exc}") from exc + logger.warning( + "Falling back to projection engine for farm_uuid=%s plant_name=%s because PCSE failed: %s", + context.farm_uuid, + context.plant_name, + exc, + ) + fallback_result = _run_projection_engine(context) + warning = f"Simulation engine failed, fallback projection used: {exc}" + return fallback_result, None, warning def summarize_growth_stages( diff --git a/crop_simulation/recommendation_optimizer.py b/crop_simulation/recommendation_optimizer.py index 3f011d5..c40dfa3 100644 --- a/crop_simulation/recommendation_optimizer.py +++ b/crop_simulation/recommendation_optimizer.py @@ -19,6 +19,11 @@ def _safe_float(value: Any, default: float = 0.0) -> float: return default +def _mm_to_cm_day(value: Any, default: float) -> float: + scaled = _safe_float(value, default * 10.0) / 10.0 + return round(max(scaled, 0.0), 4) + + def _clamp(value: float, lower: float, upper: float) -> float: return max(lower, min(value, upper)) @@ -113,7 +118,7 @@ def _build_weather_records(forecasts: list[Any], *, latitude: float, longitude: vap = max(6.0, round((humidity / 100.0) * 20.0, 3)) wind_kmh = _safe_float(getattr(forecast, "wind_speed_max", None), 7.2) wind_ms = round(wind_kmh / 3.6, 3) - et0 = _safe_float(getattr(forecast, "et0", None), 0.35) + et0 = _mm_to_cm_day(getattr(forecast, "et0", None), 0.35) records.append( { "DAY": forecast.forecast_date, @@ -125,7 +130,7 @@ def _build_weather_records(forecasts: list[Any], *, latitude: float, longitude: "TMAX": tmax, "VAP": vap, "WIND": wind_ms, - "RAIN": _safe_float(getattr(forecast, "precipitation", None), 0.0), + "RAIN": _mm_to_cm_day(getattr(forecast, "precipitation", None), 0.0), "E0": et0, "ES0": max(et0 * 0.9, 0.1), "ET0": et0, diff --git a/crop_simulation/serializers.py b/crop_simulation/serializers.py index 9162a94..b51374a 100644 --- a/crop_simulation/serializers.py +++ b/crop_simulation/serializers.py @@ -127,3 +127,25 @@ class YieldPredictionResponseSerializer(serializers.Serializer): scenarioId = serializers.IntegerField(allow_null=True) simulationWarning = serializers.CharField(allow_null=True, allow_blank=True) supportingMetrics = serializers.JSONField() + + +class YieldHarvestSummaryQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + season_year = serializers.IntegerField(required=False, help_text="سال زراعی") + crop_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول") + include_narrative = serializers.BooleanField( + required=False, + default=False, + help_text="در صورت true بودن، بخش روایت نیز در آینده اضافه می شود.", + ) + + +class YieldHarvestSummaryResponseSerializer(serializers.Serializer): + farm_uuid = serializers.CharField() + season_highlights_card = serializers.JSONField() + yield_prediction = serializers.JSONField() + harvest_prediction_card = serializers.JSONField() + harvest_readiness_zones = serializers.JSONField() + yield_quality_bands = serializers.JSONField() + harvest_operations_card = serializers.JSONField() + yield_prediction_chart = serializers.JSONField() diff --git a/crop_simulation/services.py b/crop_simulation/services.py index c5483b4..692794c 100644 --- a/crop_simulation/services.py +++ b/crop_simulation/services.py @@ -3,7 +3,7 @@ from __future__ import annotations import importlib from copy import deepcopy from dataclasses import dataclass -from datetime import date, datetime +from datetime import date, datetime, timedelta from typing import Any from django.db import transaction @@ -82,6 +82,11 @@ def _normalize_weather_records(weather: Any) -> list[dict[str, Any]]: return normalized +def _mm_to_cm_day(value: Any, default: float) -> float: + scaled = _safe_float(value, default * 10.0) / 10.0 + return round(max(scaled, 0.0), 4) + + def _normalize_agromanagement(agromanagement: Any) -> list[dict[str, Any]]: if isinstance(agromanagement, dict) and "AgroManagement" in agromanagement: campaigns = agromanagement["AgroManagement"] @@ -94,7 +99,58 @@ def _normalize_agromanagement(agromanagement: Any) -> list[dict[str, Any]]: if not campaigns: raise CropSimulationError("Agromanagement input cannot be empty.") - return campaigns + return _ensure_trailing_empty_campaign(campaigns) + + +def _ensure_trailing_empty_campaign(campaigns: list[dict[str, Any]]) -> list[dict[str, Any]]: + normalized = list(campaigns) + if not normalized: + return normalized + last_campaign = normalized[-1] + if _is_explicit_empty_campaign(last_campaign): + return normalized + + trailing = _build_trailing_empty_campaign(normalized) + if last_campaign == {}: + normalized[-1] = trailing + else: + normalized.append(trailing) + return normalized + + +def _is_explicit_empty_campaign(campaign: dict[str, Any]) -> bool: + if not isinstance(campaign, dict) or len(campaign) != 1: + return False + start_date, payload = next(iter(campaign.items())) + return isinstance(start_date, date) and payload is None + + +def _build_trailing_empty_campaign(campaigns: list[dict[str, Any]]) -> dict[date, None]: + last_campaign = next((item for item in reversed(campaigns) if isinstance(item, dict) and item), None) + if not last_campaign: + return {date.today(): None} + + campaign_start, campaign_payload = next(iter(last_campaign.items())) + candidate_dates = [_coerce_date(campaign_start)] + + if isinstance(campaign_payload, dict): + crop_calendar = campaign_payload.get("CropCalendar") or {} + for field_name in ("crop_end_date", "crop_start_date"): + value = crop_calendar.get(field_name) + if value: + candidate_dates.append(_coerce_date(value)) + + for bucket_name in ("TimedEvents",): + for event_group in campaign_payload.get(bucket_name, []) or []: + if not isinstance(event_group, dict): + continue + for event in event_group.get("events_table", []) or []: + if not isinstance(event, dict) or not event: + continue + event_date = next(iter(event.keys())) + candidate_dates.append(_coerce_date(event_date)) + + return {max(candidate_dates) + timedelta(days=1): None} def _deep_copy_json_like(value: Any) -> Any: @@ -296,7 +352,7 @@ def _build_default_agromanagement(crop_name: str, weather: list[dict[str, Any]]) first_day = weather[0]["DAY"] last_day = weather[-1]["DAY"] crop_end = max(last_day, first_day + (last_day - first_day)) - return [ + return _ensure_trailing_empty_campaign([ { first_day: { "CropCalendar": { @@ -312,7 +368,7 @@ def _build_default_agromanagement(crop_name: str, weather: list[dict[str, Any]]) "StateEvents": [], } } - ] + ]) def _build_weather_from_forecasts(forecasts: list[Any], *, latitude: float, longitude: float) -> list[dict[str, Any]]: @@ -333,10 +389,11 @@ def _build_weather_from_forecasts(forecasts: list[Any], *, latitude: float, long ), "VAP": max(_safe_float(forecast.humidity_mean, 55.0) / 5.0, 6.0), "WIND": _safe_float(forecast.wind_speed_max, 7.2) / 3.6, - "RAIN": _safe_float(forecast.precipitation, 0.0), - "E0": _safe_float(forecast.et0, 0.35), - "ES0": max(_safe_float(forecast.et0, 0.35) * 0.9, 0.1), - "ET0": _safe_float(forecast.et0, 0.35), + # WeatherForecast stores precipitation/ET0 in mm/day, while PCSE expects cm/day. + "RAIN": _mm_to_cm_day(forecast.precipitation, 0.0), + "E0": _mm_to_cm_day(forecast.et0, 0.35), + "ES0": max(round(_mm_to_cm_day(forecast.et0, 0.35) * 0.9, 4), 0.1), + "ET0": _mm_to_cm_day(forecast.et0, 0.35), } for forecast in forecasts ] @@ -352,6 +409,17 @@ def _normalize_site_parameters_for_model( soil = soil_parameters or {} site.setdefault("WAV", _safe_float(site.get("WAV"), DEFAULT_WAV)) + smw = _safe_float(soil.get("SMW"), 0.14) + smfcf = _safe_float(soil.get("SMFCF"), 0.34) + sm0 = _safe_float( + _pick_first_not_none(soil.get("SM0"), soil.get("SMMAX")), + min(max(smfcf + 0.08, smw + 0.12), 0.6), + ) + site.setdefault("IFUNRN", 0) + site.setdefault("NOTINF", 0.0) + site.setdefault("SSI", 0.0) + site.setdefault("SSMAX", 0.0) + site.setdefault("SMLIM", round(_clamp(_safe_float(site.get("SMLIM"), smfcf), smw, sm0), 3)) if model_name.startswith("Wofost81_NWLP"): navaili = _pick_first_not_none( site.get("NAVAILI"), @@ -416,6 +484,14 @@ def build_simulation_payload_from_farm( top_depth = depths[0] if depths else None smfcf = _clamp(_safe_float(getattr(top_depth, "wv0033", None), 0.34), 0.2, 0.55) smw = _clamp(_safe_float(getattr(top_depth, "wv1500", None), 0.14), 0.05, max(smfcf - 0.02, 0.06)) + sm0 = _clamp( + _safe_float( + _pick_first_not_none(getattr(top_depth, "porosity", None), getattr(top_depth, "wv0000", None)), + min(max(smfcf + 0.08, smw + 0.12), 0.6), + ), + max(smfcf + 0.02, smw + 0.05), + 0.8, + ) soil_moisture = _sensor_metric(farm, "soil_moisture") wav = ( round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3) @@ -431,7 +507,11 @@ def build_simulation_payload_from_farm( resolved_soil = { "SMFCF": round(smfcf, 3), "SMW": round(smw, 3), + "SM0": round(sm0, 3), "RDMSOL": 120.0, + "CRAIRC": 0.06, + "SOPE": 10.0, + "KSUB": 10.0, "soil_moisture": soil_moisture, "nitrogen": _safe_float(nitrogen, DEFAULT_NAVAILI), "phosphorus": _safe_float(phosphorus, 0.0), @@ -454,6 +534,11 @@ def build_simulation_payload_from_farm( "K_STATUS": _safe_float(potassium, 0.0), "SOIL_PH": _safe_float(soil_ph, 7.0), "EC": _safe_float(ec, 0.0), + "IFUNRN": 0, + "NOTINF": 0.0, + "SSI": 0.0, + "SSMAX": 0.0, + "SMLIM": round(_clamp(_safe_float(_pick_first_not_none(site_parameters and site_parameters.get("SMLIM"), smfcf), smfcf), smw, sm0), 3), } if site_parameters: resolved_site.update(site_parameters) @@ -469,10 +554,11 @@ def build_simulation_payload_from_farm( resolved_crop.update(crop_parameters) resolved_crop.setdefault("crop_name", plant_name or getattr(plant, "name", "crop")) resolved_crop.setdefault("farm_uuid", str(farm_uuid)) - resolved_crop.setdefault("soil_ph", _safe_float(soil_ph, 7.0)) resolved_crop.setdefault("soil_nitrogen", _safe_float(nitrogen, DEFAULT_NAVAILI)) resolved_crop.setdefault("soil_phosphorus", _safe_float(phosphorus, 0.0)) resolved_crop.setdefault("soil_potassium", _safe_float(potassium, 0.0)) + # Keep pH in soil/site payloads only; duplicating it in cropdata breaks some PCSE parameter providers. + resolved_crop.pop("soil_ph", None) default_agromanagement = ( deepcopy(simulation_profile.get("agromanagement")) diff --git a/crop_simulation/test_growth_simulation_api.py b/crop_simulation/test_growth_simulation_api.py index 82188f5..6e7aff6 100644 --- a/crop_simulation/test_growth_simulation_api.py +++ b/crop_simulation/test_growth_simulation_api.py @@ -102,6 +102,19 @@ class PlantGrowthSimulationApiTests(TestCase): self.assertEqual(response.status_code, 202) self.assertEqual(response.json()["data"]["task_id"], "growth-task-1") + def test_queue_api_returns_400_for_missing_weather_and_farm_uuid(self): + response = self.client.post( + "/growth/", + data={ + "plant_name": self.plant.name, + "dynamic_parameters": ["DVS", "LAI"], + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["code"], 400) + @patch("crop_simulation.views._get_async_result") def test_status_api_returns_paginated_stages(self, mock_get_async_result): stage_timeline = [ @@ -159,6 +172,31 @@ class PlantGrowthSimulationApiTests(TestCase): self.assertEqual(len(payload["stages_page"]), 1) self.assertEqual(payload["stages_page"][0]["stage_code"], "vegetative") + @patch("crop_simulation.views._get_async_result") + def test_status_api_returns_pending_state(self, mock_get_async_result): + mock_get_async_result.return_value = SimpleNamespace(state="PENDING") + + response = self.client.get("/growth/growth-task-1/status/") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["status"], "PENDING") + self.assertIn("message", payload) + + @patch("crop_simulation.views._get_async_result") + def test_status_api_returns_failure_state(self, mock_get_async_result): + mock_get_async_result.return_value = SimpleNamespace( + state="FAILURE", + result=RuntimeError("task crashed"), + ) + + response = self.client.get("/growth/growth-task-1/status/") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["status"], "FAILURE") + self.assertEqual(payload["error"], "task crashed") + @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( @@ -218,6 +256,37 @@ class PlantGrowthSimulationApiTests(TestCase): self.assertEqual(payload["current_state"]["leaf_count_estimate"], 140.0) self.assertEqual(payload["series"][0]["key"], "leaf_count_estimate") + def test_current_farm_chart_api_returns_400_for_missing_farm_uuid(self): + response = self.client.post( + "/current-farm-chart/", + data={"plant_name": self.plant.name}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["code"], 400) + + @patch("crop_simulation.views.apps.get_app_config") + def test_current_farm_chart_api_returns_500_when_simulator_fails(self, mock_get_app_config): + mock_simulator = SimpleNamespace( + simulate=lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("simulator offline")) + ) + 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, 500) + self.assertEqual(response.json()["code"], 500) + @patch("crop_simulation.views.apps.get_app_config") def test_harvest_prediction_api_returns_payload(self, mock_get_app_config): mock_service = SimpleNamespace( @@ -254,6 +323,38 @@ class PlantGrowthSimulationApiTests(TestCase): self.assertEqual(payload["daysUntil"], 43) self.assertEqual(payload["gddDetails"]["simulation_engine"], "growth_projection") + def test_harvest_prediction_api_returns_400_for_missing_farm_uuid(self): + response = self.client.post( + "/harvest-prediction/", + data={"plant_name": self.plant.name}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["code"], 400) + + @patch("crop_simulation.views.apps.get_app_config") + def test_harvest_prediction_api_returns_500_when_service_fails(self, mock_get_app_config): + class BrokenService: + def get_harvest_prediction(self, **_kwargs): + raise RuntimeError("harvest offline") + + mock_get_app_config.return_value = SimpleNamespace( + get_harvest_prediction_service=lambda: BrokenService() + ) + + 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, 500) + self.assertEqual(response.json()["code"], 500) + @patch("crop_simulation.views.apps.get_app_config") def test_yield_prediction_api_returns_payload(self, mock_get_app_config): mock_service = SimpleNamespace( @@ -288,3 +389,70 @@ class PlantGrowthSimulationApiTests(TestCase): payload = response.json()["data"] self.assertEqual(payload["predictedYieldTons"], 5.4) self.assertEqual(payload["sourceUnit"], "kg/ha") + + def test_yield_prediction_api_returns_400_for_missing_farm_uuid(self): + response = self.client.post( + "/yield-prediction/", + data={"plant_name": self.plant.name}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["code"], 400) + + @patch("crop_simulation.views.apps.get_app_config") + def test_yield_prediction_api_returns_500_when_service_fails(self, mock_get_app_config): + class BrokenService: + def get_yield_prediction(self, **_kwargs): + raise RuntimeError("yield offline") + + mock_get_app_config.return_value = SimpleNamespace( + get_yield_prediction_service=lambda: BrokenService() + ) + + 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, 500) + self.assertEqual(response.json()["code"], 500) + + @patch("crop_simulation.views.YieldHarvestSummaryService") + def test_yield_harvest_summary_api_returns_payload(self, mock_service_cls): + mock_service_cls.return_value.get_summary.return_value = { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "season_highlights_card": {"title": "Season highlights", "subtitle": "Good season."}, + "yield_prediction": {"predicted_yield_tons": 5.4, "explanation": "Stable projection."}, + "harvest_prediction_card": {"harvest_date": "2026-05-14"}, + "harvest_readiness_zones": {"averageReadiness": 74, "summary": "Readiness improving."}, + "yield_quality_bands": {"primary_quality_grade": "A"}, + "harvest_operations_card": {"steps": [{"key": "harvesting", "note": "Prepare combine."}]}, + "yield_prediction_chart": {"series": [], "xAxis": {"type": "datetime"}}, + } + + response = self.client.get( + "/yield-harvest-summary/?farm_uuid=550e8400-e29b-41d4-a716-446655440000" + "&season_year=1404&crop_name=wheat&include_narrative=true" + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000") + self.assertEqual(payload["yield_quality_bands"]["primary_quality_grade"], "A") + mock_service_cls.return_value.get_summary.assert_called_once_with( + farm_uuid="550e8400-e29b-41d4-a716-446655440000", + season_year="1404", + crop_name="wheat", + include_narrative=True, + ) + + def test_yield_harvest_summary_api_returns_400_for_missing_farm_uuid(self): + response = self.client.get("/yield-harvest-summary/?season_year=1404&crop_name=wheat") + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["code"], 400) diff --git a/crop_simulation/urls.py b/crop_simulation/urls.py index 21a83b5..de6f068 100644 --- a/crop_simulation/urls.py +++ b/crop_simulation/urls.py @@ -5,6 +5,7 @@ from .views import ( HarvestPredictionView, PlantGrowthSimulationStatusView, PlantGrowthSimulationView, + YieldHarvestSummaryView, YieldPredictionView, ) @@ -12,6 +13,7 @@ from .views import ( urlpatterns = [ path("current-farm-chart/", CurrentFarmSimulationChartView.as_view(), name="current-farm-chart"), path("harvest-prediction/", HarvestPredictionView.as_view(), name="harvest-prediction"), + path("yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"), path("yield-prediction/", YieldPredictionView.as_view(), name="yield-prediction"), path("growth/", PlantGrowthSimulationView.as_view(), name="growth-simulation"), path( diff --git a/crop_simulation/views.py b/crop_simulation/views.py index 9d66cd2..3ade56f 100644 --- a/crop_simulation/views.py +++ b/crop_simulation/views.py @@ -2,7 +2,7 @@ from __future__ import annotations from django.apps import apps -from drf_spectacular.utils import OpenApiExample, extend_schema +from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView @@ -22,10 +22,13 @@ from .serializers import ( GrowthSimulationResultSerializer, HarvestPredictionRequestSerializer, HarvestPredictionResponseSerializer, + YieldHarvestSummaryQuerySerializer, + YieldHarvestSummaryResponseSerializer, YieldPredictionRequestSerializer, YieldPredictionResponseSerializer, ) from .tasks import run_growth_simulation_task +from .yield_harvest_summary import YieldHarvestSummaryService GrowthSimulationQueuedResponseSerializer = build_envelope_serializer( @@ -196,6 +199,10 @@ YieldPredictionEnvelopeSerializer = build_envelope_serializer( "YieldPredictionEnvelopeSerializer", YieldPredictionResponseSerializer, ) +YieldHarvestSummaryEnvelopeSerializer = build_envelope_serializer( + "YieldHarvestSummaryEnvelopeSerializer", + YieldHarvestSummaryResponseSerializer, +) class CurrentFarmSimulationChartView(APIView): @@ -349,3 +356,91 @@ class YieldPredictionView(APIView): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK) + + +class YieldHarvestSummaryView(APIView): + @extend_schema( + tags=["Crop Simulation"], + summary="خلاصه عملکرد و برداشت", + description=( + "خروجی داشبورد Yield & Harvest Summary را با اتکا به داده های قطعی شبیه سازی برمی گرداند. " + "فعلا پاسخ به صورت mock با کارت های خالی بازگردانده می شود." + ), + parameters=[ + OpenApiParameter( + name="farm_uuid", + type=str, + location=OpenApiParameter.QUERY, + required=True, + description="شناسه یکتای مزرعه", + ), + OpenApiParameter( + name="season_year", + type=int, + location=OpenApiParameter.QUERY, + required=False, + description="سال زراعی", + ), + OpenApiParameter( + name="crop_name", + type=str, + location=OpenApiParameter.QUERY, + required=False, + description="نام محصول", + ), + OpenApiParameter( + name="include_narrative", + type=bool, + location=OpenApiParameter.QUERY, + required=False, + description="در آینده روایت متنی را نیز اضافه می کند.", + ), + ], + responses={ + 200: build_response( + YieldHarvestSummaryEnvelopeSerializer, + "خروجی خلاصه عملکرد و برداشت مزرعه.", + ), + 400: build_response( + GrowthSimulationErrorSerializer, + "پارامترهای query نامعتبر است.", + ), + }, + examples=[ + OpenApiExample( + "نمونه پاسخ yield harvest summary", + value={ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "season_highlights_card": {}, + "yield_prediction": {}, + "harvest_prediction_card": {}, + "harvest_readiness_zones": {}, + "yield_quality_bands": {}, + "harvest_operations_card": {}, + "yield_prediction_chart": {}, + }, + }, + response_only=True, + ), + ], + ) + def get(self, request): + serializer = YieldHarvestSummaryQuerySerializer(data=request.query_params) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + validated = serializer.validated_data + service = YieldHarvestSummaryService() + payload = service.get_summary( + farm_uuid=str(validated["farm_uuid"]), + season_year=str(validated.get("season_year") or ""), + crop_name=validated.get("crop_name") or "", + include_narrative=validated.get("include_narrative", False), + ) + return Response({"code": 200, "msg": "success", "data": payload}, status=status.HTTP_200_OK) diff --git a/crop_simulation/yield_harvest_summary.py b/crop_simulation/yield_harvest_summary.py new file mode 100644 index 0000000..eba2eac --- /dev/null +++ b/crop_simulation/yield_harvest_summary.py @@ -0,0 +1,985 @@ +from __future__ import annotations + +import copy +import logging +import math +from datetime import date, datetime +from typing import Any, Callable + +from django.apps import apps +from django.conf import settings + +from farm_data.models import SensorData +from farm_data.services import get_farm_details +from location_data.models import NdviObservation, SoilLocation + +from rag.services.yield_harvest import YieldHarvestRAGService + +logger = logging.getLogger(__name__) + + +class YieldHarvestSummaryService: + def get_summary( + self, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool = True, + ) -> dict[str, Any]: + farm_context = self._get_farm_context(farm_uuid) + farm_context["season_year"] = season_year + farm_context["crop_name"] = crop_name or farm_context.get("crop_name") or "" + yield_prediction = self._build_yield_prediction( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name, + include_narrative=include_narrative, + farm_context=farm_context, + ) + harvest_prediction_card = self._build_harvest_prediction_card( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name, + include_narrative=include_narrative, + farm_context=farm_context, + ) + harvest_readiness_zones = self._build_harvest_readiness_zones( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name, + include_narrative=include_narrative, + farm_context=farm_context, + ) + yield_quality_bands = self._build_yield_quality_bands( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name, + include_narrative=include_narrative, + farm_context=farm_context, + ) + harvest_operations_card = self._build_harvest_operations_card( + farm_context=farm_context, + harvest_prediction_card=harvest_prediction_card, + pcse_dvs_stage=self._extract_pcse_dvs_stage(harvest_prediction_card), + ) + yield_prediction_chart = self._build_yield_prediction_chart( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name, + include_narrative=include_narrative, + farm_context=farm_context, + ) + season_highlights_card = self._build_season_highlights_card( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name, + include_narrative=include_narrative, + farm_context=farm_context, + yield_prediction=yield_prediction, + harvest_prediction_card=harvest_prediction_card, + harvest_readiness_zones=harvest_readiness_zones, + yield_quality_bands=yield_quality_bands, + ) + + deterministic_payload = { + "farm_uuid": farm_uuid, + "season_highlights_card": season_highlights_card, + "yield_prediction": yield_prediction, + "harvest_prediction_card": harvest_prediction_card, + "harvest_readiness_zones": harvest_readiness_zones, + "yield_quality_bands": yield_quality_bands, + "harvest_operations_card": harvest_operations_card, + "yield_prediction_chart": yield_prediction_chart, + } + context_payload = { + **copy.deepcopy(deterministic_payload), + "farm_context": farm_context, + } + + if not include_narrative: + return deterministic_payload + + try: + rag_service = YieldHarvestRAGService() + narrative_data = rag_service.generate_narrative(context_payload) + except Exception as exc: + logger.warning( + "Yield harvest narrative generation failed for farm_uuid=%s: %s", + farm_uuid, + exc, + ) + narrative_data = {} + return self._merge_narrative(deterministic_payload, narrative_data) + + def _build_yield_prediction( + self, + *, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool, + farm_context: dict[str, Any], + ) -> dict[str, Any]: + service = apps.get_app_config("crop_simulation").get_yield_prediction_service() + result = service.get_yield_prediction( + farm_uuid=farm_uuid, + plant_name=crop_name or None, + ) + supporting_metrics = dict(result.get("supportingMetrics") or {}) + + # Secondary KPIs are placeholders until dedicated deterministic formulas land. + supporting_metrics.setdefault( + "estimatedKpis", + { + "season_year": season_year, + "applied_rule": "simple_placeholder_rules", + "is_estimated": True, + }, + ) + + return { + "farm_uuid": result.get("farm_uuid", farm_uuid), + "crop_name": result.get("plant_name") or crop_name, + "season_year": season_year, + "predicted_yield_tons": result.get("predictedYieldTons"), + "predicted_yield_raw": result.get("predictedYieldRaw"), + "unit": result.get("unit"), + "source_unit": result.get("sourceUnit"), + "simulation_engine": result.get("simulationEngine"), + "simulation_model": result.get("simulationModel"), + "scenario_id": result.get("scenarioId"), + "simulation_warning": result.get("simulationWarning"), + "secondary_kpis_estimated": True, + "descriptionSource": "deterministic", + "farm_context": { + "soil_type": farm_context.get("soil", {}).get("soil_type"), + "soil_data_provider": farm_context.get("soil", {}).get("provider"), + }, + "supporting_metrics": supporting_metrics, + } + + def _build_harvest_prediction_card( + self, + *, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool, + farm_context: dict[str, Any], + ) -> dict[str, Any]: + service = apps.get_app_config("crop_simulation").get_harvest_prediction_service() + result = service.get_harvest_prediction( + farm_uuid=farm_uuid, + plant_name=crop_name or None, + ) + + fallback_description = ( + f"Deterministic harvest forecast for {crop_name or 'the selected crop'} " + f"in season {season_year}." + ) + + return { + "farm_uuid": farm_uuid, + "crop_name": crop_name, + "season_year": season_year, + "harvest_date": result.get("date"), + "harvest_date_formatted": result.get("dateFormatted"), + "days_until": result.get("daysUntil"), + "optimal_window_start": result.get("optimalWindowStart"), + "optimal_window_end": result.get("optimalWindowEnd"), + "description": result.get("description") or fallback_description, + "descriptionSource": "deterministic", + "field_conditions": { + "soil_moisture": farm_context.get("recent_sensor_averages", {}).get("soil_moisture"), + "soil_temperature": farm_context.get("recent_sensor_averages", {}).get("soil_temperature"), + }, + "readiness_metrics": result.get("gddDetails") or {}, + } + + def _build_yield_prediction_chart( + self, + *, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool, + farm_context: dict[str, Any], + ) -> dict[str, Any]: + simulator = apps.get_app_config("crop_simulation").get_current_farm_chart_simulator() + result = simulator.simulate( + farm_uuid=farm_uuid, + plant_name=crop_name or None, + ) + pcse_timeseries = list(result.get("daily_output") or []) + + yield_series: list[list[float]] = [] + biomass_series: list[list[float]] = [] + for item in pcse_timeseries: + timestamp = self._to_unix_timestamp(item.get("DAY")) + if timestamp is None: + continue + + twso = self._safe_chart_value(item.get("TWSO")) + if twso is not None: + yield_series.append([timestamp, twso]) + + tagp = self._safe_chart_value(item.get("TAGP")) + if tagp is not None: + biomass_series.append([timestamp, tagp]) + + return { + "farm_uuid": farm_uuid, + "crop_name": result.get("plant_name") or crop_name, + "season_year": season_year, + "series": [ + { + "name": "Predicted Yield", + "type": "line", + "data": yield_series, + }, + { + "name": "Biomass", + "type": "area", + "data": biomass_series, + }, + ], + "xAxis": {"type": "datetime"}, + "meta": { + "unit": "kg/ha", + "simulation_engine": result.get("engine"), + "simulation_model": result.get("model_name"), + "scenario_id": result.get("scenario_id"), + "simulation_warning": result.get("simulation_warning"), + "field_context": { + "soil_type": farm_context.get("soil", {}).get("soil_type"), + "center_coordinates": farm_context.get("center_coordinates"), + }, + }, + } + + def _build_harvest_operations_card( + self, + *, + farm_context: dict[str, Any], + harvest_prediction_card: dict[str, Any], + pcse_dvs_stage: float, + ) -> dict[str, Any]: + days_until = int(harvest_prediction_card.get("days_until") or 0) + stage_label, phase_name = self._map_dvs_to_phase(pcse_dvs_stage) + steps = self._build_operations_steps( + phase_name=phase_name, + days_until=days_until, + soil_moisture=farm_context.get("recent_sensor_averages", {}).get("soil_moisture"), + ) + + return { + "farm_uuid": farm_context.get("farm_uuid"), + "crop_name": farm_context.get("crop_name"), + "season_year": farm_context.get("season_year"), + "stage_label": stage_label, + "phase_name": phase_name, + "days_until_harvest": days_until, + "current_dvs": round(pcse_dvs_stage, 4), + "summary": ( + f"Operations are prioritized for {farm_context.get('crop_name') or 'the selected crop'} " + f"with {days_until} days remaining until the predicted harvest window." + ), + "rules_source": "deterministic_dvs_rules", + "field_context": { + "soil_type": farm_context.get("soil", {}).get("soil_type"), + "soil_moisture": farm_context.get("recent_sensor_averages", {}).get("soil_moisture"), + "soil_temperature": farm_context.get("recent_sensor_averages", {}).get("soil_temperature"), + }, + "steps": steps, + } + + def _build_season_highlights_card( + self, + *, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool, + farm_context: dict[str, Any], + yield_prediction: dict[str, Any], + harvest_prediction_card: dict[str, Any], + harvest_readiness_zones: dict[str, Any], + yield_quality_bands: dict[str, Any], + ) -> dict[str, Any]: + primary_quality_grade = ( + yield_quality_bands.get("primary_quality_grade") + or yield_quality_bands.get("top_band") + or yield_quality_bands.get("summary") + ) + average_readiness = harvest_readiness_zones.get("averageReadiness") + total_predicted_yield = yield_prediction.get("predicted_yield_tons") + target_harvest_date = ( + harvest_prediction_card.get("harvest_date_formatted") + or harvest_prediction_card.get("harvest_date") + ) + estimated_revenue = self._get_estimated_revenue( + farm_uuid=farm_uuid, + total_predicted_yield=total_predicted_yield, + ) + return { + "farm_uuid": farm_uuid, + "crop_name": crop_name, + "season_year": season_year, + "title": "Season highlights", + # Left blank for narrative merge unless a non-LLM fallback is needed later. + "subtitle": "", + "total_predicted_yield": total_predicted_yield, + "yield_unit": yield_prediction.get("unit"), + "target_harvest_date": target_harvest_date, + "days_until_harvest": harvest_prediction_card.get("days_until"), + "average_readiness": average_readiness, + "primary_quality_grade": primary_quality_grade, + "estimated_revenue": estimated_revenue, + "soil_type": farm_context.get("soil", {}).get("soil_type"), + } + + def _build_harvest_readiness_zones( + self, + *, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool, + farm_context: dict[str, Any], + ) -> dict[str, Any]: + sensor = ( + SensorData.objects.select_related("center_location") + .filter(farm_uuid=farm_uuid) + .first() + ) + if sensor is None or sensor.center_location is None: + return { + "farm_uuid": farm_uuid, + "averageReadiness": None, + "zones": [], + "source": "ndvi_health_service", + } + + location = sensor.center_location + ndvi_service = apps.get_app_config("location_data").get_ndvi_health_service() + health_card = ndvi_service.get_ndvi_health(farm_uuid=farm_uuid) + + observations = list( + location.ndvi_observations.order_by("-observation_date", "-created_at")[:2] + ) + latest_observation = observations[0] if observations else None + previous_observation = observations[1] if len(observations) > 1 else None + + latest_ndvi = self._safe_float(health_card.get("mean_ndvi"), None) + previous_ndvi = self._safe_float( + previous_observation.mean_ndvi if previous_observation else None, + None, + ) + ndvi_trend = None + if latest_ndvi is not None and previous_ndvi is not None: + ndvi_trend = round(latest_ndvi - previous_ndvi, 4) + + grid = {} + if latest_observation and isinstance(latest_observation.ndvi_map, dict): + grid = latest_observation.ndvi_map + ndvi_grid = grid.get("grid") if isinstance(grid, dict) else None + + zones: list[dict[str, Any]] = [] + if isinstance(ndvi_grid, list) and ndvi_grid: + zone_index = 1 + for row_index, row in enumerate(ndvi_grid): + if not isinstance(row, list): + continue + for col_index, cell in enumerate(row): + cell_ndvi = self._safe_chart_value(cell) + if cell_ndvi is None: + continue + readiness = self._ndvi_to_readiness(cell_ndvi, ndvi_trend) + zones.append( + { + "zoneId": f"zone-{zone_index}", + "zoneLabel": f"Zone {zone_index}", + "gridPosition": {"row": row_index, "col": col_index}, + "meanNdvi": cell_ndvi, + "readiness": readiness, + "daysUntil": self._estimate_days_until_from_readiness(readiness), + "status": self._readiness_status(readiness), + } + ) + zone_index += 1 + + if not zones and latest_ndvi is not None: + readiness = self._ndvi_to_readiness(latest_ndvi, ndvi_trend) + zones.append( + { + "zoneId": "zone-center", + "zoneLabel": "Center field zone", + "gridPosition": None, + "meanNdvi": latest_ndvi, + "readiness": readiness, + "daysUntil": self._estimate_days_until_from_readiness(readiness), + "status": self._readiness_status(readiness), + } + ) + + average_readiness = None + if zones: + average_readiness = round( + sum(zone["readiness"] for zone in zones) / len(zones), + 2, + ) + + return { + "farm_uuid": farm_uuid, + "observationDate": ( + latest_observation.observation_date.isoformat() + if latest_observation + else health_card.get("observation_date") + ), + "vegetationHealthClass": health_card.get("vegetation_health_class"), + "meanNdvi": latest_ndvi, + "ndviTrend": ndvi_trend, + "averageReadiness": average_readiness, + "zones": zones, + "source": "ndvi_health_service", + } + + def _to_unix_timestamp(self, value: Any) -> int | None: + if isinstance(value, datetime): + return int(value.timestamp() * 1000) + if isinstance(value, date): + return int(datetime.combine(value, datetime.min.time()).timestamp() * 1000) + if isinstance(value, str): + try: + if "T" in value: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + else: + parsed = datetime.combine(date.fromisoformat(value), datetime.min.time()) + return int(parsed.timestamp() * 1000) + except ValueError: + return None + return None + + def _safe_chart_value(self, value: Any) -> float | None: + parsed = self._safe_float(value, None) + if parsed is None or math.isnan(parsed) or math.isinf(parsed): + return None + return round(parsed, 4) + + def _ndvi_to_readiness(self, mean_ndvi: float, trend_delta: float | None) -> int: + base_score = ((0.75 - mean_ndvi) / 0.55) * 100.0 + if trend_delta is not None and trend_delta < 0: + # A falling NDVI near season end suggests drying and harvest readiness. + base_score += min(abs(trend_delta) * 120.0, 18.0) + if trend_delta is not None and trend_delta > 0.05: + base_score -= min(trend_delta * 80.0, 12.0) + return int(round(max(0.0, min(base_score, 100.0)))) + + def _estimate_days_until_from_readiness(self, readiness: int) -> int: + return max(int(round((100 - readiness) / 12.0)), 0) + + def _readiness_status(self, readiness: int) -> str: + if readiness >= 80: + return "ready" + if readiness >= 55: + return "approaching" + if readiness >= 30: + return "monitoring" + return "not_ready" + + def _build_yield_quality_bands( + self, + *, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool, + farm_context: dict[str, Any], + ) -> dict[str, Any]: + crop_key = (crop_name or farm_context.get("crop_name") or "").strip().lower() + yield_service = apps.get_app_config("crop_simulation").get_yield_prediction_service() + yield_payload = yield_service.get_yield_prediction( + farm_uuid=farm_uuid, + plant_name=crop_name or None, + ) + predicted_yield_raw = self._safe_float(yield_payload.get("predictedYieldRaw"), 0.0) or 0.0 + soil_metrics = farm_context.get("soil", {}).get("resolved_metrics") or {} + sensor_metrics = farm_context.get("recent_sensor_averages") or {} + + try: + service = self._resolve_service( + getter_names=( + "get_yield_quality_bands_service", + "get_quality_grading_service", + "get_quality_model_service", + ) + ) + method = self._resolve_service_method( + service, + method_names=( + "get_yield_quality_bands", + "get_quality_bands", + "grade_yield_quality", + ), + ) + return method( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name or None, + include_narrative=include_narrative, + ) + except AttributeError: + pass + + protein_content = self._estimate_protein_content( + crop_key=crop_key, + nitrogen_value=self._safe_float(soil_metrics.get("nitrogen"), None), + predicted_yield_raw=predicted_yield_raw, + ) + moisture_percent = self._estimate_moisture_percent( + crop_key=crop_key, + soil_moisture=sensor_metrics.get("soil_moisture"), + ) + quality_score = self._estimate_quality_score( + protein_content=protein_content, + moisture_percent=moisture_percent, + predicted_yield_raw=predicted_yield_raw, + ) + grade_distribution = self._build_grade_distribution(quality_score) + primary_quality_grade = max( + grade_distribution, + key=lambda item: item.get("share_percent", 0), + )["grade"] + + return { + "farm_uuid": farm_uuid, + "crop_name": crop_name, + "season_year": season_year, + "source": "deterministic_grading_rules", + "is_estimated": True, + "protein_content": { + "value": protein_content, + "unit": "%", + }, + "moisture_percentage": { + "value": moisture_percent, + "unit": "%", + }, + "grade_distribution": grade_distribution, + "primary_quality_grade": primary_quality_grade, + "quality_score": quality_score, + "summary": f"Primary quality grade is {primary_quality_grade}.", + } + + def _get_estimated_revenue( + self, + *, + farm_uuid: str, + total_predicted_yield: float | None, + ) -> float | None: + try: + service = apps.get_app_config("economy").get_economic_overview_service() + overview = service.get_economic_overview(farm_uuid=farm_uuid) + except Exception: + return None + + if not isinstance(overview, dict): + return None + + price_per_ton = None + for item in overview.get("economicData") or []: + if not isinstance(item, dict): + continue + title = str(item.get("title") or "").lower() + value = item.get("value") + if "price" in title or "قیمت" in title: + price_per_ton = self._extract_numeric(value) + break + + if price_per_ton is None or total_predicted_yield is None: + return None + return round(total_predicted_yield * price_per_ton, 2) + + def _estimate_protein_content( + self, + *, + crop_key: str, + nitrogen_value: float | None, + predicted_yield_raw: float, + ) -> float: + nitrogen_factor = 0.0 if nitrogen_value is None else min(nitrogen_value / 2500.0, 2.0) + yield_factor = min(predicted_yield_raw / 10000.0, 1.5) + if "wheat" in crop_key or "گندم" in crop_key: + base = 11.8 + return round(base + (nitrogen_factor * 1.2) - (yield_factor * 0.35), 2) + if "barley" in crop_key or "جو" in crop_key: + base = 10.4 + return round(base + (nitrogen_factor * 0.9) - (yield_factor * 0.25), 2) + return round(9.5 + (nitrogen_factor * 0.8), 2) + + def _estimate_moisture_percent( + self, + *, + crop_key: str, + soil_moisture: float | None, + ) -> float: + soil_component = 0.0 if soil_moisture is None else min(max((soil_moisture - 20.0) / 10.0, -2.0), 4.0) + if "wheat" in crop_key or "barley" in crop_key or "گندم" in crop_key or "جو" in crop_key: + return round(12.6 + soil_component, 2) + return round(11.8 + soil_component, 2) + + def _estimate_quality_score( + self, + *, + protein_content: float, + moisture_percent: float, + predicted_yield_raw: float, + ) -> int: + protein_score = min(max((protein_content / 14.0) * 50.0, 0.0), 50.0) + moisture_penalty = min(abs(moisture_percent - 12.5) * 4.5, 22.0) + yield_bonus = min(predicted_yield_raw / 1500.0, 18.0) + score = protein_score + yield_bonus + 32.0 - moisture_penalty + return int(round(max(0.0, min(score, 100.0)))) + + def _build_grade_distribution(self, quality_score: int) -> list[dict[str, Any]]: + if quality_score >= 85: + return [ + {"grade": "A", "share_percent": 62}, + {"grade": "B", "share_percent": 28}, + {"grade": "C", "share_percent": 10}, + ] + if quality_score >= 70: + return [ + {"grade": "A", "share_percent": 38}, + {"grade": "B", "share_percent": 44}, + {"grade": "C", "share_percent": 18}, + ] + return [ + {"grade": "A", "share_percent": 16}, + {"grade": "B", "share_percent": 41}, + {"grade": "C", "share_percent": 43}, + ] + + def _extract_numeric(self, value: Any) -> float | None: + if isinstance(value, (int, float)): + return float(value) + if not isinstance(value, str): + return None + cleaned = "".join(ch for ch in value if ch.isdigit() or ch in {".", "-"}) + return self._safe_float(cleaned, None) + + def _get_farm_context( + self, + farm_uuid: str, + ) -> dict[str, Any]: + farm = ( + SensorData.objects.select_related("center_location", "weather_forecast") + .prefetch_related("center_location__depths", "plants") + .filter(farm_uuid=farm_uuid) + .first() + ) + if farm is None: + return { + "farm_uuid": farm_uuid, + "center_coordinates": None, + "soil": {"provider": getattr(settings, "SOIL_DATA_PROVIDER", "unknown")}, + "recent_sensor_averages": {}, + } + + farm_details = get_farm_details(str(farm_uuid)) or {} + center_location = farm.center_location + soil_details = (farm_details.get("soil") or {}).get("resolved_metrics") or {} + weather_details = farm_details.get("weather") or {} + recent_sensor_averages = { + "soil_moisture": self._safe_float(soil_details.get("soil_moisture", farm.soil_moisture), None), + "soil_temperature": self._safe_float(soil_details.get("soil_temperature", farm.soil_temperature), None), + "air_temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None), + } + + crop_name = "" + plant_names = farm_details.get("plants") or [] + if plant_names: + first_plant = plant_names[0] + if isinstance(first_plant, dict): + crop_name = str(first_plant.get("name") or "") + + return { + "farm_uuid": farm_uuid, + "crop_name": crop_name, + "center_coordinates": { + "lat": float(center_location.latitude), + "lon": float(center_location.longitude), + }, + "farm_boundary": farm_details.get("center_location", {}).get("farm_boundary"), + "soil": { + "provider": getattr(settings, "SOIL_DATA_PROVIDER", "unknown"), + "soil_type": self._infer_soil_type(soil_details), + "resolved_metrics": soil_details, + }, + "recent_sensor_averages": recent_sensor_averages, + "weather": { + "temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None), + "temperature_min": self._safe_float(weather_details.get("temperature_min"), None), + "temperature_max": self._safe_float(weather_details.get("temperature_max"), None), + }, + "source_models": { + "sensor_data": SensorData.__name__, + "soil_location": SoilLocation.__name__, + }, + } + + def _extract_pcse_dvs_stage(self, harvest_prediction_card: dict[str, Any]) -> float: + readiness_metrics = harvest_prediction_card.get("readiness_metrics") or {} + forecast = readiness_metrics.get("daily_gdd_forecast") or [{}] + return self._safe_float(forecast[-1].get("development_stage"), 0.0) or 0.0 + + def _map_dvs_to_phase(self, dvs: float) -> tuple[str, str]: + if dvs >= 2.0: + return "ready", "maturity" + if dvs >= 1.7: + return "final_pre_harvest", "late_reproductive" + if dvs >= 1.2: + return "mid_pre_harvest", "grain_fill" + if dvs >= 0.8: + return "monitoring", "reproductive_transition" + return "early_pre_harvest", "vegetative" + + def _build_operations_steps( + self, + *, + phase_name: str, + days_until: int, + soil_moisture: float | None, + ) -> list[dict[str, Any]]: + field_ready = soil_moisture is None or soil_moisture <= 35.0 + + if phase_name == "maturity": + return [ + { + "key": "desiccation", + "title": "Desiccation check", + "status": "ready", + "is_completed": False, + "estimated_days": 0, + }, + { + "key": "harvesting", + "title": "Harvesting", + "status": "ready" if field_ready else "watch_field_conditions", + "is_completed": False, + "estimated_days": max(min(days_until, 2), 0), + }, + { + "key": "transportation", + "title": "Transportation", + "status": "ready", + "is_completed": False, + "estimated_days": max(min(days_until + 1, 3), 1), + }, + ] + if phase_name == "late_reproductive": + return [ + { + "key": "equipment_check", + "title": "Inspect harvest equipment", + "status": "priority", + "is_completed": False, + "estimated_days": 1, + }, + { + "key": "labor_plan", + "title": "Confirm labor and transport plan", + "status": "priority", + "is_completed": False, + "estimated_days": 2, + }, + { + "key": "field_entry", + "title": "Verify field access and dry windows", + "status": "ready" if field_ready else "monitor", + "is_completed": False, + "estimated_days": max(min(days_until, 5), 1), + }, + ] + if phase_name == "grain_fill": + return [ + { + "key": "monitor_maturity", + "title": "Track maturity and storage organ growth", + "status": "active", + "is_completed": False, + "estimated_days": 7, + }, + { + "key": "review_readiness", + "title": "Review zone readiness differences", + "status": "active", + "is_completed": False, + "estimated_days": 10, + }, + { + "key": "prepare_logistics", + "title": "Prepare harvest logistics plan", + "status": "upcoming", + "is_completed": False, + "estimated_days": 14, + }, + ] + return [ + { + "key": "weekly_monitoring", + "title": "Run weekly crop maturity checks", + "status": "active", + "is_completed": False, + "estimated_days": 14, + }, + { + "key": "update_forecast", + "title": "Refresh harvest timing forecast", + "status": "active", + "is_completed": False, + "estimated_days": 10, + }, + { + "key": "draft_operations", + "title": "Draft harvest operation checklist", + "status": "upcoming", + "is_completed": False, + "estimated_days": 21, + }, + ] + + def _infer_soil_type(self, soil_metrics: dict[str, Any]) -> str | None: + sand = self._safe_float(soil_metrics.get("sand"), None) + clay = self._safe_float(soil_metrics.get("clay"), None) + silt = self._safe_float(soil_metrics.get("silt"), None) + if sand is None or clay is None or silt is None: + return None + if clay >= 40: + return "clay" + if sand >= 70 and clay <= 15: + return "sandy" + if silt >= 50 and clay < 27: + return "silty_loam" + return "loam" + + def _safe_float(self, value: Any, default: float | None = 0.0) -> float | None: + try: + if value in (None, ""): + return default + return float(value) + except (TypeError, ValueError): + return default + + def _merge_narrative( + self, + final_payload: dict[str, Any], + narratives: dict[str, Any], + ) -> dict[str, Any]: + merged = copy.deepcopy(final_payload) + if not isinstance(narratives, dict): + narratives = {} + + season_card = merged.setdefault("season_highlights_card", {}) + fallback_subtitle = self._default_season_highlights_subtitle(merged) + season_card["subtitle"] = self._coalesce_text( + narratives.get("season_highlights_subtitle"), + season_card.get("subtitle"), + fallback_subtitle, + ) + + yield_card = merged.setdefault("yield_prediction", {}) + fallback_yield_explanation = self._default_yield_prediction_explanation(merged) + yield_card["explanation"] = self._coalesce_text( + narratives.get("yield_prediction_explanation"), + yield_card.get("explanation"), + fallback_yield_explanation, + ) + + readiness_card = merged.setdefault("harvest_readiness_zones", {}) + fallback_readiness_summary = self._default_harvest_readiness_summary(merged) + readiness_card["summary"] = self._coalesce_text( + narratives.get("harvest_readiness_summary"), + readiness_card.get("summary"), + fallback_readiness_summary, + ) + + operations_card = merged.setdefault("harvest_operations_card", {}) + deterministic_steps = operations_card.get("steps") + operation_notes = narratives.get("operation_notes") + if isinstance(deterministic_steps, list): + note_items = operation_notes if isinstance(operation_notes, list) else [] + for index, step in enumerate(deterministic_steps): + if not isinstance(step, dict): + continue + fallback_note = self._default_operation_note(step) + candidate_note = note_items[index] if index < len(note_items) else None + step["note"] = self._coalesce_text( + candidate_note, + step.get("note"), + fallback_note, + ) + + return merged + + def _coalesce_text(self, *values: Any) -> str: + for value in values: + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + def _default_season_highlights_subtitle(self, payload: dict[str, Any]) -> str: + highlights = payload.get("season_highlights_card") or {} + total_yield = highlights.get("total_predicted_yield") + unit = highlights.get("yield_unit") or "" + harvest_date = highlights.get("target_harvest_date") or "the predicted harvest window" + if total_yield is None: + return f"Harvest is targeted for {harvest_date} based on the deterministic season outlook." + return f"Predicted yield is {total_yield} {unit} and harvest is targeted for {harvest_date}.".strip() + + def _default_yield_prediction_explanation(self, payload: dict[str, Any]) -> str: + yield_card = payload.get("yield_prediction") or {} + predicted = yield_card.get("predicted_yield_tons") + unit = yield_card.get("unit") or "" + if predicted is None: + return "Yield forecast is based on the deterministic crop simulation output." + return f"Yield forecast is based on the deterministic crop simulation and currently projects {predicted} {unit}.".strip() + + def _default_harvest_readiness_summary(self, payload: dict[str, Any]) -> str: + readiness = payload.get("harvest_readiness_zones") or {} + average = readiness.get("averageReadiness") + if average is None: + return "Harvest readiness is derived from the latest deterministic zone signals." + return f"Average harvest readiness is {average} based on the latest deterministic zone signals.".strip() + + def _default_operation_note(self, step: dict[str, Any]) -> str: + title = step.get("title") or "This operation" + status = step.get("status") or "planned" + estimate = step.get("estimated_days") + if estimate is None: + return f"{title} is currently marked as {status}." + return f"{title} is {status} with an estimated timing of {estimate} days.".strip() + + def _resolve_service(self, *, getter_names: tuple[str, ...]) -> Any: + app_config = apps.get_app_config("crop_simulation") + for getter_name in getter_names: + getter = getattr(app_config, getter_name, None) + if callable(getter): + return getter() + raise AttributeError( + f"None of the expected service getters were found on crop_simulation app config: {getter_names}" + ) + + def _resolve_service_method( + self, + service: Any, + *, + method_names: tuple[str, ...], + ) -> Callable[..., dict[str, Any]]: + for method_name in method_names: + method = getattr(service, method_name, None) + if callable(method): + return method + raise AttributeError( + f"None of the expected service methods were found on {service.__class__.__name__}: {method_names}" + ) diff --git a/docs/crop_simulation_api_reference.md b/docs/crop_simulation_api_reference.md new file mode 100644 index 0000000..ddd21bb --- /dev/null +++ b/docs/crop_simulation_api_reference.md @@ -0,0 +1,1183 @@ +# Crop Simulation API Reference + +این فایل توضیح کامل API های ماژول `crop_simulation` را پوشش می دهد: + +- `POST /api/crop-simulation/current-farm-chart/` +- `POST /api/crop-simulation/growth/` +- `GET /api/crop-simulation/growth/{task_id}/status/` +- `POST /api/crop-simulation/harvest-prediction/` +- `GET /api/crop-simulation/yield-harvest-summary/` +- `POST /api/crop-simulation/yield-prediction/` + +--- + +## الگوی کلی پاسخ ها + +تقریبا همه endpoint ها از الگوی envelope زیر استفاده می کنند: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +### معنی فیلدهای envelope + +| فیلد | نوع | توضیح | +|---|---|---| +| `code` | integer | کد منطقی پاسخ. معمولا با HTTP status همسو است. | +| `msg` | string | پیام کوتاه پاسخ. در موفقیت معمولا `success` و در خطا متن فارسی خطا است. | +| `data` | object / null | بدنه اصلی داده. در خطاهای validation معمولا شامل جزئیات خطا است. | + +### خطاهای رایج + +| HTTP Status | `code` | توضیح | +|---|---|---| +| `400` | `400` | ورودی نامعتبر است؛ مثل نبودن `farm_uuid` یا ناقص بودن payload | +| `500` | `500` | اجرای سرویس یا شبیه سازی داخلی با خطا مواجه شده است | +| `202` | `202` | تسک async با موفقیت در صف قرار گرفته است | + +--- + +## 1) POST `/api/crop-simulation/current-farm-chart/` + +### کاربرد + +برای ساخت chart وضعیت فعلی مزرعه با استفاده از شبیه سازی رشد. +این endpoint داده هایی مثل: + +- روند روزانه رشد +- بیوماس +- وزن محصول +- شاخص سطح برگ +- رطوبت خاک + +را برمی گرداند. + +### درخواست + +#### Body + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی" +} +``` + +### فیلدهای ورودی + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | string (UUID) | بله | شناسه یکتای مزرعه | +| `plant_name` | string | خیر | نام گیاه. اگر ارسال نشود سیستم سعی می کند گیاه پیش فرض مزرعه را تشخیص دهد | + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "engine": "growth_projection", + "model_name": "growth_projection_v1", + "scenario_id": 12, + "simulation_warning": null, + "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": [] + } +} +``` + +### توضیح کامل فیلدهای پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `farm_uuid` | string / null | شناسه مزرعه | +| `plant_name` | string | نام گیاهی که شبیه سازی برای آن انجام شده | +| `engine` | string / null | موتور شبیه سازی استفاده شده | +| `model_name` | string / null | نام مدل شبیه سازی | +| `scenario_id` | integer / null | شناسه سناریو اگر در سیستم ثبت شده باشد | +| `simulation_warning` | string / null | هشدار غیر بحرانی؛ مثلا وقتی fallback استفاده شده | +| `categories` | array[string] | محور زمانی نمودار؛ معمولا تاریخ های روزانه | +| `series` | array[object] | سری های نمودار برای رندر frontend | +| `summary` | array[object] | کارت های خلاصه برای نمایش سریع وضعیت فعلی | +| `current_state` | object | وضعیت آخرین روز شبیه سازی | +| `metrics` | object | شاخص های محاسبه شده نهایی | +| `daily_output` | array[object] | خروجی روزانه خام شبیه سازی | + +### ساختار `series` + +هر عضو `series` معمولا این ساختار را دارد: + +| فیلد | نوع | توضیح | +|---|---|---| +| `name` | string | عنوان سری برای chart | +| `key` | string | کلید فنی سری | +| `data` | array[number] | داده های عددی سری به ترتیب `categories` | + +نمونه key های مهم: + +- `leaf_count_estimate` +- `biomass_weight` +- `storage_organ_weight` +- `lai` +- `soil_moisture_percent` + +### ساختار `summary` + +هر آیتم `summary` یک KPI card است: + +| فیلد | نوع | توضیح | +|---|---|---| +| `title` | string | عنوان کارت | +| `subtitle` | string | زیرعنوان | +| `amount` | number | مقدار اصلی کارت | +| `unit` | string | واحد | +| `avatarColor` | string | رنگ پیشنهادی برای UI | +| `avatarIcon` | string | آیکن پیشنهادی برای UI | + +### ساختار `current_state` + +| فیلد | نوع | توضیح | +|---|---|---| +| `date` | string | تاریخ آخرین رکورد | +| `leaf_count_estimate` | number | تعداد برگ تخمینی | +| `leaf_area_index` | number | شاخص سطح برگ (LAI) | +| `biomass_weight` | number | وزن بیوماس | +| `storage_organ_weight` | number | وزن اندام ذخیره ای / محصول | +| `soil_moisture_percent` | number | رطوبت خاک به درصد | +| `development_stage` | number | مرحله رشد گیاه به صورت DVS | +| `gdd` | number | درجه روز رشد همان روز | + +### ساختار `daily_output` + +این بخش خروجی خام شبیه سازی است و معمولا شامل فیلدهایی مثل این هاست: + +- `DAY` +- `DVS` +- `LAI` +- `TAGP` +- `TWSO` +- `SM` +- `GDD` +- `TMIN` +- `TMAX` +- `RAIN` +- `ET0` + +### خطاها + +#### 400 + +وقتی `farm_uuid` ارسال نشود: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +#### 500 + +وقتی شبیه ساز داخلی fail شود: + +```json +{ + "code": 500, + "msg": "خطا در اجرای chart شبیه سازی مزرعه: simulator offline", + "data": null +} +``` + +--- + +## 2) POST `/api/crop-simulation/growth/` + +### کاربرد + +برای شروع شبیه سازی رشد گیاه به صورت async. +این endpoint خود نتیجه نهایی را برنمی گرداند؛ فقط یک `task_id` می دهد تا بعدا از endpoint وضعیت استفاده شود. + +### درخواست + +#### نمونه با weather مستقیم + +```json +{ + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI", "TAGP", "TWSO", "SM"], + "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 + }, + "page_size": 2 +} +``` + +#### نمونه با farm + +```json +{ + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI", "TAGP"], + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### فیلدهای ورودی + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `plant_name` | string | بله | نام گیاه | +| `dynamic_parameters` | array[string] | بله | پارامترهای رشد که باید در خروجی stageها گزارش شوند | +| `farm_uuid` | string (UUID) | شرطی | اگر weather نفرستید لازم است | +| `weather` | array/object | شرطی | اگر `farm_uuid` نفرستید لازم است | +| `soil_parameters` | object | خیر | پارامترهای خاک | +| `site_parameters` | object | خیر | پارامترهای سایت / مزرعه | +| `crop_parameters` | object | خیر | پارامترهای مدل محصول | +| `agromanagement` | object / array | خیر | مدیریت زراعی | +| `page_size` | integer | خیر | اندازه صفحه stage ها، بین `1` تا `50` | + +### نکته مهم validation + +حداقل یکی از این دو باید وجود داشته باشد: + +- `farm_uuid` +- `weather` + +### پاسخ موفق + +```json +{ + "code": 202, + "msg": "تسک شبیه سازی رشد در صف قرار گرفت.", + "data": { + "task_id": "growth-task-1", + "status_url": "/api/crop-simulation/growth/growth-task-1/status/", + "plant_name": "گوجه‌فرنگی" + } +} +``` + +### توضیح فیلدهای پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `task_id` | string | شناسه تسک Celery | +| `status_url` | string | آدرس endpoint وضعیت برای پیگیری نتیجه | +| `plant_name` | string | نام گیاه مربوط به این شبیه سازی | + +### خطاهای رایج + +#### 400 + +اگر نه `farm_uuid` و نه `weather` ارسال نشده باشد: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "non_field_errors": ["یکی از farm_uuid یا weather باید ارسال شود."] + } +} +``` + +--- + +## 3) GET `/api/crop-simulation/growth/{task_id}/status/` + +### کاربرد + +برای گرفتن وضعیت تسک async شبیه سازی رشد. + +### Query Parameters + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `page` | integer | خیر | شماره صفحه stage ها | +| `page_size` | integer | خیر | تعداد stage ها در هر صفحه، حداکثر `50` | + +### حالت های پاسخ + +این endpoint همیشه HTTP `200` می دهد، ولی فیلد `data.status` تعیین می کند تسک در چه وضعیتی است. + +مقادیر مهم: + +- `PENDING` +- `PROGRESS` +- `SUCCESS` +- `FAILURE` + +--- + +### پاسخ در حالت `PENDING` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "growth-task-1", + "status": "PENDING", + "message": "تسک در صف یا یافت نشد." + } +} +``` + +#### توضیح + +| فیلد | توضیح | +|---|---| +| `task_id` | شناسه تسک | +| `status` | وضعیت فعلی | +| `message` | پیام کمکی برای کاربر | + +--- + +### پاسخ در حالت `PROGRESS` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "growth-task-1", + "status": "PROGRESS", + "progress": { + "current": 2, + "total": 3, + "message": "simulation finished" + } + } +} +``` + +#### توضیح + +| فیلد | نوع | توضیح | +|---|---|---| +| `progress.current` | integer | مرحله فعلی پردازش | +| `progress.total` | integer | تعداد کل مراحل | +| `progress.message` | string | توضیح مرحله جاری | + +--- + +### پاسخ در حالت `SUCCESS` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "growth-task-1", + "status": "SUCCESS", + "result": { + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS"], + "engine": "growth_projection", + "model_name": "growth_projection_v1", + "scenario_id": null, + "simulation_warning": null, + "summary_metrics": {}, + "stage_timeline": [ + { + "order": 1, + "stage_code": "establishment", + "stage_name": "استقرار", + "start_date": "2026-04-01", + "end_date": "2026-04-02", + "days_count": 2, + "metrics": { + "DVS": { + "start": 0.1, + "end": 0.2, + "min": 0.1, + "max": 0.2, + "avg": 0.15 + } + } + } + ], + "stages_page": [ + { + "order": 1, + "stage_code": "establishment", + "stage_name": "استقرار", + "start_date": "2026-04-01", + "end_date": "2026-04-02", + "days_count": 2, + "metrics": { + "DVS": { + "start": 0.1, + "end": 0.2, + "min": 0.1, + "max": 0.2, + "avg": 0.15 + } + } + } + ], + "pagination": { + "page": 1, + "page_size": 1, + "total_items": 3, + "total_pages": 3, + "has_next": true, + "has_previous": false + }, + "daily_records_count": 7, + "default_page_size": 1 + } + } +} +``` + +### توضیح کامل `result` + +| فیلد | نوع | توضیح | +|---|---|---| +| `plant_name` | string | نام گیاه | +| `dynamic_parameters` | array[string] | پارامترهایی که stage metrics برایشان تولید شده | +| `engine` | string / null | موتور شبیه سازی | +| `model_name` | string / null | نام مدل | +| `scenario_id` | integer / null | شناسه سناریو | +| `simulation_warning` | string / null | هشدار غیر بحرانی | +| `summary_metrics` | object | شاخص های خلاصه | +| `stage_timeline` | array[object] | کل timeline مراحل رشد | +| `stages_page` | array[object] | فقط صفحه فعلی timeline | +| `pagination` | object | اطلاعات صفحه بندی | +| `daily_records_count` | integer | تعداد رکوردهای روزانه شبیه سازی | +| `default_page_size` | integer | اندازه صفحه پیش فرض | + +### ساختار هر stage + +| فیلد | نوع | توضیح | +|---|---|---| +| `order` | integer | ترتیب مرحله در timeline | +| `stage_code` | string | کد فنی مرحله رشد | +| `stage_name` | string | نام قابل نمایش مرحله | +| `start_date` | string | تاریخ شروع مرحله | +| `end_date` | string | تاریخ پایان مرحله | +| `days_count` | integer | تعداد روزهای این مرحله | +| `metrics` | object | خلاصه آماری پارامترهای درخواستی | + +### ساختار `metrics` در هر stage + +برای هر پارامتر مثل `DVS` یا `LAI`: + +| فیلد | توضیح | +|---|---| +| `start` | مقدار ابتدای مرحله | +| `end` | مقدار پایان مرحله | +| `min` | کمترین مقدار در مرحله | +| `max` | بیشترین مقدار در مرحله | +| `avg` | میانگین مقدار در مرحله | + +### ساختار `pagination` + +| فیلد | نوع | توضیح | +|---|---|---| +| `page` | integer | شماره صفحه فعلی | +| `page_size` | integer | اندازه صفحه فعلی | +| `total_items` | integer | تعداد کل stage ها | +| `total_pages` | integer | تعداد کل صفحه ها | +| `has_next` | boolean | آیا صفحه بعدی وجود دارد | +| `has_previous` | boolean | آیا صفحه قبلی وجود دارد | + +--- + +### پاسخ در حالت `FAILURE` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "growth-task-1", + "status": "FAILURE", + "error": "task crashed" + } +} +``` + +#### توضیح + +| فیلد | توضیح | +|---|---| +| `error` | متن خطای نهایی تسک | + +--- + +## 4) POST `/api/crop-simulation/harvest-prediction/` + +### کاربرد + +برای پیش بینی زمان تقریبی برداشت بر اساس خروجی شبیه سازی رشد و GDD. + +### درخواست + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی" +} +``` + +### فیلدهای ورودی + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | string (UUID) | بله | شناسه مزرعه | +| `plant_name` | string | خیر | نام گیاه | + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "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" + } + } +} +``` + +### توضیح کامل فیلدهای پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `date` | string | تاریخ تخمینی برداشت با فرمت ISO | +| `dateFormatted` | string | همان تاریخ به فرمت human-readable | +| `daysUntil` | integer | تعداد روز باقی مانده تا برداشت | +| `description` | string | توضیح متنی قابل نمایش برای کاربر | +| `optimalWindowStart` | string | شروع بازه مناسب برداشت | +| `optimalWindowEnd` | string | پایان بازه مناسب برداشت | +| `gddDetails` | object | جزئیات محاسبات GDD و داده های پشتیبان | + +### ساختار `gddDetails` + +معمولا شامل این فیلدهاست: + +| فیلد | نوع | توضیح | +|---|---|---| +| `current_cumulative_gdd` | number | GDD تجمعی فعلی | +| `required_gdd_for_maturity` | number | GDD مورد نیاز برای رسیدن به بلوغ | +| `remaining_gdd` | number | میزان GDD باقی مانده | +| `estimated_days_to_harvest` | integer | روزهای برآوردی تا برداشت | +| `predicted_harvest_date` | string | تاریخ برآوردی برداشت | +| `predicted_harvest_window` | object | بازه شروع/پایان مناسب برداشت | +| `daily_gdd_forecast` | array[object] | پیش بینی روزانه GDD | +| `simulation_engine` | string | موتور شبیه سازی | +| `simulation_model_name` | string | نام مدل شبیه سازی | +| `simulation_warning` | string / null | هشدار داخلی شبیه سازی | +| `scenario_id` | integer / null | شناسه سناریو | + +### ساختار `daily_gdd_forecast` + +هر آیتم: + +| فیلد | نوع | توضیح | +|---|---|---| +| `date` | string | تاریخ | +| `gdd` | number | GDD همان روز | +| `cumulative_gdd` | number | GDD تجمعی تا آن روز | +| `development_stage` | number | DVS آن روز | + +### خطاها + +#### 400 + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +#### 500 + +```json +{ + "code": 500, + "msg": "خطا در پیش بینی زمان برداشت: harvest offline", + "data": null +} +``` + +--- + +## 5) GET `/api/crop-simulation/yield-harvest-summary/` + +### کاربرد + +برای برگرداندن داشبورد کامل خلاصه عملکرد و برداشت. +این endpoint ترکیبی از چند block مختلف است: + +- season highlights +- yield prediction +- harvest prediction +- readiness zones +- quality bands +- harvest operations +- yield chart + +این endpoint می تواند علاوه بر داده های deterministic، متن های narrative را هم اضافه کند. + +### Query Parameters + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | string (UUID) | بله | شناسه یکتای مزرعه | +| `season_year` | integer | خیر | سال زراعی | +| `crop_name` | string | خیر | نام محصول | +| `include_narrative` | boolean | خیر | اگر `true` باشد متن های توضیحی RAG هم merge می شوند | + +### نمونه درخواست + +```http +GET /api/crop-simulation/yield-harvest-summary/?farm_uuid=11111111-1111-1111-1111-111111111111&season_year=1404&crop_name=wheat&include_narrative=true +``` + +### پاسخ موفق + +نمونه واقعی پاسخ فعلی: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "season_highlights_card": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "", + "season_year": "", + "title": "Season highlights", + "subtitle": "Projected harvest in 152 days with minimal yield prediction issues due to simulation fallback.", + "total_predicted_yield": 0.0, + "yield_unit": "تن", + "target_harvest_date": "28 September 2026", + "days_until_harvest": 152, + "average_readiness": null, + "primary_quality_grade": "C", + "estimated_revenue": null, + "soil_type": "loam" + }, + "yield_prediction": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "خیار", + "season_year": "", + "predicted_yield_tons": 0.0, + "predicted_yield_raw": 0.0, + "unit": "تن", + "source_unit": "kg/ha", + "simulation_engine": "growth_projection", + "simulation_model": "growth_projection_v1", + "scenario_id": null, + "simulation_warning": "Simulation engine failed, fallback projection used: Value for parameter CVL missing.", + "secondary_kpis_estimated": true, + "descriptionSource": "deterministic", + "farm_context": { + "soil_type": "loam", + "soil_data_provider": "mock" + }, + "supporting_metrics": { + "yield_estimate": 0.0, + "biomass": 232.9052, + "max_lai": 0.1063 + }, + "explanation": "شبيه ساز با محدوديت داده مواجه شد؛ براورد فعلی بدون تضمین و با روش جایگزین انجام شده است." + }, + "harvest_prediction_card": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "", + "season_year": "", + "harvest_date": "2026-09-28", + "harvest_date_formatted": "28 September 2026", + "days_until": 152, + "optimal_window_start": "2026-09-25", + "optimal_window_end": "2026-10-01", + "description": "شبيه ساز تا انتهاي forecast هنوز به رسيدگي کامل نرسيده، اما با ميانگين رشد فعلي براورد مي شود خیار حدود 152 روز ديگر به برداشت برسد.", + "descriptionSource": "deterministic", + "field_conditions": { + "soil_moisture": 42.3, + "soil_temperature": 21.4 + }, + "readiness_metrics": { + "current_cumulative_gdd": 0.0, + "required_gdd_for_maturity": 1200.0 + } + }, + "harvest_readiness_zones": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "observationDate": null, + "vegetationHealthClass": "Unavailable", + "meanNdvi": null, + "ndviTrend": null, + "averageReadiness": null, + "zones": [], + "source": "ndvi_health_service", + "summary": "خیار هنوز در مراحل اولیه رشد است، 152 روز تا برداشته شدن باقی مانده است. میانگین آمادگی موجود نیست." + }, + "yield_quality_bands": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "", + "season_year": "", + "source": "deterministic_grading_rules", + "is_estimated": true, + "protein_content": { + "value": 9.51, + "unit": "%" + }, + "moisture_percentage": { + "value": 14.03, + "unit": "%" + }, + "grade_distribution": [ + {"grade": "A", "share_percent": 16}, + {"grade": "B", "share_percent": 41}, + {"grade": "C", "share_percent": 43} + ], + "primary_quality_grade": "C", + "quality_score": 59, + "summary": "Primary quality grade is C." + }, + "harvest_operations_card": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "خیار", + "season_year": "", + "stage_label": "early_pre_harvest", + "phase_name": "vegetative", + "days_until_harvest": 152, + "current_dvs": 0.0394, + "summary": "Operations are prioritized for خیار with 152 days remaining until the predicted harvest window.", + "rules_source": "deterministic_dvs_rules", + "field_context": { + "soil_type": "loam", + "soil_moisture": 42.3, + "soil_temperature": 21.4 + }, + "steps": [ + { + "key": "weekly_monitoring", + "title": "Run weekly crop maturity checks", + "status": "active", + "is_completed": false, + "estimated_days": 14, + "note": "Check weekly crop status for any signs of maturity changes." + } + ] + }, + "yield_prediction_chart": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "خیار", + "season_year": "", + "series": [ + { + "name": "Predicted Yield", + "type": "line", + "data": [[1777420800000, 0.0]] + }, + { + "name": "Biomass", + "type": "area", + "data": [[1777420800000, 89.392]] + } + ], + "xAxis": { + "type": "datetime" + }, + "meta": { + "unit": "kg/ha", + "simulation_engine": "growth_projection", + "simulation_model": "growth_projection_v1", + "scenario_id": null, + "simulation_warning": "Simulation engine failed, fallback projection used: Value for parameter CVL missing.", + "field_context": { + "soil_type": "loam", + "center_coordinates": { + "lat": 50.0, + "lon": 50.0 + } + } + } + } + } +} +``` + +### توضیح top-level response + +| فیلد | نوع | توضیح | +|---|---|---| +| `farm_uuid` | string | شناسه مزرعه | +| `season_highlights_card` | object | خلاصه مهم ترین KPI ها | +| `yield_prediction` | object | خروجی پیش بینی عملکرد | +| `harvest_prediction_card` | object | تاریخ و وضعیت برداشت | +| `harvest_readiness_zones` | object | وضعیت آمادگی برداشت در zoneها | +| `yield_quality_bands` | object | کیفیت برآوردی محصول | +| `harvest_operations_card` | object | عملیات پیشنهادی برداشت | +| `yield_prediction_chart` | object | داده نمودار عملکرد و بیوماس | + +### توضیح `season_highlights_card` + +| فیلد | نوع | توضیح | +|---|---|---| +| `title` | string | عنوان کارت | +| `subtitle` | string | متن توضیحی کوتاه؛ ممکن است از RAG بیاید | +| `total_predicted_yield` | number / null | عملکرد پیش بینی شده | +| `yield_unit` | string | واحد عملکرد | +| `target_harvest_date` | string / null | تاریخ هدف برداشت | +| `days_until_harvest` | integer / null | روز باقی مانده | +| `average_readiness` | number / null | میانگین آمادگی zoneها | +| `primary_quality_grade` | string / null | درجه کیفیت غالب | +| `estimated_revenue` | number / null | درآمد تخمینی اگر داده اقتصادی موجود باشد | +| `soil_type` | string / null | نوع خاک | + +### توضیح `yield_prediction` + +| فیلد | نوع | توضیح | +|---|---|---| +| `predicted_yield_tons` | number | عملکرد بر حسب تن | +| `predicted_yield_raw` | number | عملکرد خام معمولا بر حسب `kg/ha` | +| `unit` | string | واحد نمایشی | +| `source_unit` | string | واحد منبع | +| `simulation_engine` | string / null | موتور شبیه سازی | +| `simulation_model` | string / null | نام مدل | +| `scenario_id` | integer / null | شناسه سناریو | +| `simulation_warning` | string / null | هشدار شبیه سازی | +| `secondary_kpis_estimated` | boolean | آیا KPIهای ثانویه تخمینی هستند | +| `descriptionSource` | string | منبع توضیح؛ معمولا `deterministic` یا `rag` | +| `farm_context` | object | بخشی از context مزرعه | +| `supporting_metrics` | object | متریک های پشتیبان مثل `yield_estimate` و `biomass` | +| `explanation` | string | توضیح متنی برای کاربر | + +### توضیح `harvest_prediction_card` + +| فیلد | نوع | توضیح | +|---|---|---| +| `harvest_date` | string | تاریخ ISO برداشت | +| `harvest_date_formatted` | string | تاریخ قابل نمایش | +| `days_until` | integer | تعداد روز باقی مانده | +| `optimal_window_start` | string | شروع پنجره مناسب برداشت | +| `optimal_window_end` | string | پایان پنجره مناسب برداشت | +| `description` | string | توضیح متنی | +| `descriptionSource` | string | منبع متن | +| `field_conditions` | object | شرایط فعلی مزرعه مثل رطوبت و دما | +| `readiness_metrics` | object | جزئیات محاسبات readiness/GDD | + +### توضیح `harvest_readiness_zones` + +| فیلد | نوع | توضیح | +|---|---|---| +| `observationDate` | string / null | تاریخ مشاهده NDVI | +| `vegetationHealthClass` | string / null | کلاس سلامت پوشش گیاهی | +| `meanNdvi` | number / null | NDVI میانگین | +| `ndviTrend` | number / null | روند تغییر NDVI | +| `averageReadiness` | number / null | میانگین آمادگی zoneها | +| `zones` | array[object] | فهرست zoneها | +| `source` | string | منبع داده | +| `summary` | string | توضیح متنی خلاصه | + +### ساختار هر zone + +| فیلد | نوع | توضیح | +|---|---|---| +| `zoneId` | string | شناسه zone | +| `zoneLabel` | string | نام نمایشی zone | +| `gridPosition` | object / null | موقعیت zone در grid | +| `meanNdvi` | number | میانگین NDVI zone | +| `readiness` | integer | درصد آمادگی برداشت | +| `daysUntil` | integer | روزهای تخمینی تا آمادگی | +| `status` | string | وضعیت مثل `ready`, `approaching`, `monitoring`, `not_ready` | + +### توضیح `yield_quality_bands` + +| فیلد | نوع | توضیح | +|---|---|---| +| `source` | string | منبع محاسبه کیفیت | +| `is_estimated` | boolean | آیا مقادیر تخمینی هستند | +| `protein_content` | object | درصد پروتئین | +| `moisture_percentage` | object | درصد رطوبت | +| `grade_distribution` | array[object] | توزیع درصدی gradeها | +| `primary_quality_grade` | string | grade غالب | +| `quality_score` | number | امتیاز کیفیت | +| `summary` | string | خلاصه متنی کیفیت | + +### ساختار `protein_content` و `moisture_percentage` + +| فیلد | نوع | توضیح | +|---|---|---| +| `value` | number | مقدار | +| `unit` | string | واحد، معمولا `%` | + +### ساختار `grade_distribution` + +| فیلد | نوع | توضیح | +|---|---|---| +| `grade` | string | گرید کیفیت مثل `A`, `B`, `C` | +| `share_percent` | integer | سهم درصدی آن گرید | + +### توضیح `harvest_operations_card` + +| فیلد | نوع | توضیح | +|---|---|---| +| `stage_label` | string | برچسب مرحله عملیاتی | +| `phase_name` | string | نام فاز رشد | +| `days_until_harvest` | integer | روز باقی مانده تا برداشت | +| `current_dvs` | number | DVS فعلی | +| `summary` | string | خلاصه عملیاتی | +| `rules_source` | string | منبع قواعد تصمیم | +| `field_context` | object | context مزرعه | +| `steps` | array[object] | گام های عملیاتی | + +### ساختار هر step + +| فیلد | نوع | توضیح | +|---|---|---| +| `key` | string | کلید فنی step | +| `title` | string | عنوان عملیات | +| `status` | string | وضعیت مثل `active`, `upcoming`, `ready` | +| `is_completed` | boolean | آیا انجام شده | +| `estimated_days` | integer | روز برآوردی برای انجام / رسیدن | +| `note` | string | توضیح تکمیلی یا توصیه | + +### توضیح `yield_prediction_chart` + +| فیلد | نوع | توضیح | +|---|---|---| +| `series` | array[object] | سری های نمودار عملکرد و بیوماس | +| `xAxis` | object | تنظیم محور افقی | +| `meta` | object | متادیتای chart | + +### ساختار `yield_prediction_chart.series` + +| فیلد | نوع | توضیح | +|---|---|---| +| `name` | string | نام سری | +| `type` | string | نوع رسم مثل `line` یا `area` | +| `data` | array[[timestamp, value]] | داده های نمودار بر پایه timestamp یونیکس میلی ثانیه | + +### توضیح `yield_prediction_chart.meta` + +| فیلد | نوع | توضیح | +|---|---|---| +| `unit` | string | واحد داده chart | +| `simulation_engine` | string | موتور شبیه سازی | +| `simulation_model` | string | مدل | +| `scenario_id` | integer / null | شناسه سناریو | +| `simulation_warning` | string / null | هشدار شبیه سازی | +| `field_context` | object | context مزرعه مثل نوع خاک و مختصات | + +### خطاها + +#### 400 + +وقتی `farm_uuid` ارسال نشده باشد: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +--- + +## 6) POST `/api/crop-simulation/yield-prediction/` + +### کاربرد + +برای تبدیل خروجی شبیه سازی رشد به پیش بینی عملکرد مزرعه. + +### درخواست + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی" +} +``` + +### فیلدهای ورودی + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | string (UUID) | بله | شناسه مزرعه | +| `plant_name` | string | خیر | نام گیاه | + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "plant_name": "گوجه‌فرنگی", + "predictedYieldTons": 5.4, + "predictedYieldRaw": 5400.0, + "unit": "تن", + "sourceUnit": "kg/ha", + "simulationEngine": "growth_projection", + "simulationModel": "growth_projection_v1", + "scenarioId": 12, + "simulationWarning": null, + "supportingMetrics": { + "yield_estimate": 5400.0 + } + } +} +``` + +### توضیح کامل فیلدهای پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `farm_uuid` | string | شناسه مزرعه | +| `plant_name` | string / null | نام گیاه | +| `predictedYieldTons` | number | عملکرد پیش بینی شده بر حسب تن | +| `predictedYieldRaw` | number | مقدار خام عملکرد | +| `unit` | string | واحد نمایشی، معمولا `تن` | +| `sourceUnit` | string | واحد محاسباتی اصلی، معمولا `kg/ha` | +| `simulationEngine` | string / null | موتور شبیه سازی | +| `simulationModel` | string / null | مدل شبیه سازی | +| `scenarioId` | integer / null | شناسه سناریو | +| `simulationWarning` | string / null | هشدار شبیه سازی | +| `supportingMetrics` | object | متریک های پشتیبان مورد استفاده برای محاسبه عملکرد | + +### خطاها + +#### 400 + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +#### 500 + +```json +{ + "code": 500, + "msg": "خطا در پیش بینی عملکرد: yield offline", + "data": null +} +``` + +--- + +## جمع بندی تفاوت endpoint ها + +| Endpoint | کاربرد اصلی | sync/async | +|---|---|---| +| `POST /current-farm-chart/` | ساخت نمودار وضعیت فعلی مزرعه | sync | +| `POST /growth/` | شروع شبیه سازی رشد | async | +| `GET /growth/{task_id}/status/` | بررسی وضعیت و نتیجه شبیه سازی رشد | async status | +| `POST /harvest-prediction/` | پیش بینی زمان برداشت | sync | +| `GET /yield-harvest-summary/` | داشبورد کامل عملکرد و برداشت | sync | +| `POST /yield-prediction/` | پیش بینی عملکرد نهایی | sync | + +--- + +## نکات مهم برای frontend + +- در endpoint `growth/status` همیشه به `data.status` نگاه کنید، نه فقط HTTP status. +- در پاسخ های chart و summary ممکن است `simulation_warning` مقدار داشته باشد، حتی اگر HTTP `200` باشد. +- در `yield-harvest-summary` بعضی فیلدها ممکن است `null` باشند، مخصوصا: + - `estimated_revenue` + - `average_readiness` + - `scenario_id` + - `simulation_warning` +- در `yield_prediction_chart.series[].data` timestamp ها بر حسب **milliseconds** هستند. +- در `yield-harvest-summary` اگر `include_narrative=true` باشد، بعضی متن ها ممکن است از RAG بیایند ولی اعداد deterministic باقی می مانند. + +--- + +## مسیر فایل + +این داکیومنت در مسیر زیر ذخیره شده است: + +`docs/crop_simulation_api_reference.md` diff --git a/rag/services/__init__.py b/rag/services/__init__.py index d446bb5..63aea07 100644 --- a/rag/services/__init__.py +++ b/rag/services/__init__.py @@ -7,6 +7,7 @@ from .fertilization import get_fertilization_recommendation from .pest_disease import get_pest_disease_detection, get_pest_disease_risk from .soil_anomaly import get_soil_anomaly_insight from .water_need_prediction import get_water_need_prediction_insight +from .yield_harvest import YieldHarvestRAGService __all__ = [ "get_irrigation_recommendation", @@ -15,4 +16,5 @@ __all__ = [ "get_pest_disease_risk", "get_soil_anomaly_insight", "get_water_need_prediction_insight", + "YieldHarvestRAGService", ] diff --git a/rag/services/yield_harvest.py b/rag/services/yield_harvest.py new file mode 100644 index 0000000..2b3ef23 --- /dev/null +++ b/rag/services/yield_harvest.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from pydantic import BaseModel, Field, ValidationError + +from rag.api_provider import get_chat_client +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, +) +from rag.config import RAGConfig, get_service_config, load_rag_config + +logger = logging.getLogger(__name__) + +SERVICE_ID = "yield_harvest" + +YIELD_HARVEST_PROMPT = ( + "You are an expert agronomist writing concise dashboard narratives for farmers. " + "Return only valid JSON matching this schema exactly: " + "{" + '"season_highlights_subtitle": string, ' + '"yield_prediction_explanation": string, ' + '"harvest_readiness_summary": string, ' + '"operation_notes": [string, ...]' + "}. " + "Do not add markdown, explanations, or extra keys. " + "Strict Golden Rule: do not invent numbers, dates, prices, revenues, percentages, KPIs, scores, or measurements. " + "Use only values already present in the deterministic context. " + "If a fact is missing from the context, say less rather than guessing." +) + + +class YieldHarvestNarrativeSchema(BaseModel): + season_highlights_subtitle: str + yield_prediction_explanation: str + harvest_readiness_summary: str + operation_notes: list[str] = Field(default_factory=list) + + +class YieldHarvestRAGService: + def generate_narrative( + self, + deterministic_context: dict[str, Any], + ) -> dict[str, Any]: + cfg = load_rag_config() + service, client, model = self._build_service_client(cfg) + structured_context = self._build_structured_context( + deterministic_context=deterministic_context, + ) + user_prompt = ( + "Generate short user-friendly narrative fields for the Yield & Harvest Summary dashboard " + "using only the deterministic context. Keep the language practical and agronomy-focused." + ) + system_prompt, messages = self._build_messages( + service=service, + cfg=cfg, + structured_context=structured_context, + query=user_prompt, + ) + + farm_uuid = str(deterministic_context.get("farm_uuid") or "") + audit_log = None + if farm_uuid: + try: + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + model=model, + query=user_prompt, + system_prompt=system_prompt, + messages=messages, + ) + except Exception as exc: + logger.warning("Yield harvest audit log creation failed for %s: %s", farm_uuid, exc) + + try: + response = client.chat.completions.create( + model=model, + messages=messages, + response_format={"type": "json_object"}, + ) + raw = (response.choices[0].message.content or "").strip() + parsed = self._clean_json(raw) + validated = YieldHarvestNarrativeSchema.model_validate(parsed) + if audit_log is not None: + _complete_audit_log(audit_log, raw) + return { + "season_highlights_subtitle": validated.season_highlights_subtitle, + "yield_prediction_explanation": validated.yield_prediction_explanation, + "harvest_readiness_summary": validated.harvest_readiness_summary, + "operation_notes": validated.operation_notes, + } + except (ValidationError, ValueError, KeyError, IndexError) as exc: + logger.warning("Yield harvest narrative parsing failed for farm_uuid=%s: %s", farm_uuid, exc) + if audit_log is not None: + _fail_audit_log(audit_log, str(exc)) + return {} + except Exception as exc: + logger.error("Yield harvest narrative LLM call failed for farm_uuid=%s: %s", farm_uuid, exc) + if audit_log is not None: + _fail_audit_log(audit_log, str(exc)) + return {} + + def _build_service_client(self, cfg: RAGConfig): + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + return service, client, service.llm.model + + def _build_messages( + self, + *, + service: Any, + cfg: RAGConfig, + structured_context: dict[str, Any], + query: str, + ) -> tuple[str, list[dict[str, str]]]: + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(YIELD_HARVEST_PROMPT) + system_parts.append( + "[deterministic_context]\n" + + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str) + ) + system_prompt = "\n\n".join(part for part in system_parts if part) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": query}, + ] + return system_prompt, messages + + def _build_structured_context( + self, + *, + deterministic_context: dict[str, Any], + ) -> dict[str, Any]: + season = deterministic_context.get("season_highlights_card") or {} + harvest = deterministic_context.get("harvest_prediction_card") or {} + operations = deterministic_context.get("harvest_operations_card") or {} + yield_prediction = deterministic_context.get("yield_prediction") or {} + readiness = deterministic_context.get("harvest_readiness_zones") or {} + + operation_steps = [] + for step in operations.get("steps") or []: + if not isinstance(step, dict): + continue + operation_steps.append( + { + "key": step.get("key"), + "title": step.get("title"), + "status": step.get("status"), + } + ) + + return { + "farm_context": deterministic_context.get("farm_context") or {}, + "yield_prediction": { + "predicted_yield_tons": yield_prediction.get("predicted_yield_tons"), + "unit": yield_prediction.get("unit"), + "simulation_warning": yield_prediction.get("simulation_warning"), + "supporting_metrics": yield_prediction.get("supporting_metrics"), + }, + "season_highlights_card": { + "title": season.get("title"), + "subtitle": season.get("subtitle"), + "total_predicted_yield": season.get("total_predicted_yield"), + "yield_unit": season.get("yield_unit"), + "target_harvest_date": season.get("target_harvest_date"), + "days_until_harvest": season.get("days_until_harvest"), + "average_readiness": season.get("average_readiness"), + "primary_quality_grade": season.get("primary_quality_grade"), + "estimated_revenue": season.get("estimated_revenue"), + }, + "harvest_prediction_card": { + "harvest_date": harvest.get("harvest_date"), + "harvest_date_formatted": harvest.get("harvest_date_formatted"), + "days_until": harvest.get("days_until"), + "optimal_window_start": harvest.get("optimal_window_start"), + "optimal_window_end": harvest.get("optimal_window_end"), + "description": harvest.get("description"), + }, + "harvest_readiness_zones": { + "average_readiness": readiness.get("averageReadiness"), + "mean_ndvi": readiness.get("meanNdvi"), + "ndvi_trend": readiness.get("ndviTrend"), + "zones": readiness.get("zones"), + }, + "harvest_operations_card": { + "stage_label": operations.get("stage_label"), + "days_until_harvest": operations.get("days_until_harvest"), + "current_dvs": operations.get("current_dvs"), + "summary": operations.get("summary"), + "steps": operation_steps, + }, + } + + def _clean_json(self, raw: str) -> dict[str, Any]: + cleaned = (raw or "").strip() + if cleaned.startswith("```"): + cleaned = cleaned.strip("`") + if cleaned.startswith("json"): + cleaned = cleaned[4:] + cleaned = cleaned.strip() + if not cleaned: + raise ValueError("Yield harvest narrative response was empty.") + try: + parsed = json.loads(cleaned) + except (json.JSONDecodeError, ValueError) as exc: + raise ValueError("Yield harvest narrative response was not valid JSON.") from exc + if not isinstance(parsed, dict): + raise ValueError("Yield harvest narrative response root must be a JSON object.") + return parsed