This commit is contained in:
2026-04-30 00:53:47 +03:30
parent 88f56da582
commit 46ba01e4cc
13 changed files with 2925 additions and 20 deletions
+76 -8
View File
@@ -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(
+7 -2
View File
@@ -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,
+22
View File
@@ -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()
+95 -9
View File
@@ -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"))
@@ -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)
+2
View File
@@ -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(
+96 -1
View File
@@ -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)
+985
View File
@@ -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}"
)