UPDATE
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user