2026-04-25 17:22:41 +03:30
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from datetime import date, timedelta
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from farm_data.models import SensorData
|
|
|
|
|
from plant.gdd import resolve_growth_profile
|
|
|
|
|
|
|
|
|
|
from .growth_simulation import (
|
|
|
|
|
DEFAULT_DYNAMIC_PARAMETERS,
|
|
|
|
|
DEFAULT_PAGE_SIZE,
|
|
|
|
|
GrowthSimulationError,
|
|
|
|
|
_run_simulation,
|
|
|
|
|
build_growth_context,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_float(value: Any, default: float = 0.0) -> float:
|
|
|
|
|
try:
|
|
|
|
|
if value in (None, ""):
|
|
|
|
|
return default
|
|
|
|
|
return float(value)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _harvest_description(
|
|
|
|
|
*,
|
|
|
|
|
plant_name: str,
|
|
|
|
|
current_gdd: float,
|
|
|
|
|
required_gdd: float,
|
|
|
|
|
remaining_gdd: float,
|
|
|
|
|
estimated_days: int,
|
|
|
|
|
maturity_reached_in_simulation: bool,
|
|
|
|
|
) -> str:
|
|
|
|
|
if maturity_reached_in_simulation:
|
|
|
|
|
return (
|
|
|
|
|
f"شبيه ساز رشد نشان مي دهد {plant_name} با روند فعلي در حدود {estimated_days} روز ديگر "
|
|
|
|
|
f"به بازه برداشت مي رسد. تا امروز {round(current_gdd, 1)} واحد-روز رشد ثبت شده و "
|
|
|
|
|
f"نياز باقيمانده تا بلوغ حدود {round(remaining_gdd, 1)} واحد-روز است."
|
|
|
|
|
)
|
|
|
|
|
return (
|
|
|
|
|
f"شبيه ساز تا انتهاي forecast هنوز به رسيدگي کامل نرسيده، اما با ميانگين رشد فعلي "
|
|
|
|
|
f"براورد مي شود {plant_name} حدود {estimated_days} روز ديگر به برداشت برسد. "
|
|
|
|
|
f"تا امروز {round(current_gdd, 1)} از {round(required_gdd, 1)} واحد-روز مورد نياز طي شده است."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_harvest_prediction_payload(*, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
|
|
|
|
|
resolved_plant_name = plant_name
|
|
|
|
|
if not resolved_plant_name:
|
|
|
|
|
sensor = SensorData.objects.prefetch_related("plants").filter(farm_uuid=farm_uuid).first()
|
|
|
|
|
if sensor is None:
|
2026-04-30 02:10:15 +03:30
|
|
|
raise GrowthSimulationError("مزرعه پیدا نشد.")
|
2026-04-25 17:22:41 +03:30
|
|
|
plant = sensor.plants.first()
|
|
|
|
|
if plant is None:
|
2026-04-30 02:10:15 +03:30
|
|
|
raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.")
|
2026-04-25 17:22:41 +03:30
|
|
|
resolved_plant_name = plant.name
|
|
|
|
|
|
|
|
|
|
context = build_growth_context(
|
|
|
|
|
{
|
|
|
|
|
"farm_uuid": farm_uuid,
|
|
|
|
|
"plant_name": resolved_plant_name,
|
|
|
|
|
"dynamic_parameters": DEFAULT_DYNAMIC_PARAMETERS,
|
|
|
|
|
"page_size": DEFAULT_PAGE_SIZE,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
simulation_result, scenario_id, simulation_warning = _run_simulation(context)
|
|
|
|
|
daily_output = simulation_result.get("daily_output") or []
|
|
|
|
|
if not daily_output:
|
2026-04-30 02:10:15 +03:30
|
|
|
raise GrowthSimulationError("هیچ خروجی شبیه سازی در دسترس نیست.")
|
2026-04-25 17:22:41 +03:30
|
|
|
|
|
|
|
|
profile = resolve_growth_profile(context.plant)
|
|
|
|
|
required_gdd = _safe_float(profile.get("required_gdd_for_maturity"), 1200.0)
|
|
|
|
|
current_gdd = _safe_float(profile.get("current_cumulative_gdd"), 0.0)
|
|
|
|
|
|
|
|
|
|
cumulative_gdd = current_gdd
|
|
|
|
|
maturity_date = None
|
|
|
|
|
daily_gdd_forecast = []
|
|
|
|
|
for item in daily_output:
|
|
|
|
|
day_gdd = _safe_float(item.get("GDD"), 0.0)
|
|
|
|
|
cumulative_gdd += day_gdd
|
|
|
|
|
day_value = item.get("DAY")
|
|
|
|
|
iso_day = day_value.isoformat() if isinstance(day_value, date) else str(day_value)
|
|
|
|
|
daily_gdd_forecast.append(
|
|
|
|
|
{
|
|
|
|
|
"date": iso_day,
|
|
|
|
|
"gdd": round(day_gdd, 3),
|
|
|
|
|
"cumulative_gdd": round(cumulative_gdd, 3),
|
|
|
|
|
"development_stage": round(_safe_float(item.get("DVS"), 0.0), 4),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
if _safe_float(item.get("DVS"), 0.0) >= 2.0 or cumulative_gdd >= required_gdd:
|
|
|
|
|
maturity_date = date.fromisoformat(iso_day)
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
maturity_reached_in_simulation = maturity_date is not None
|
|
|
|
|
if maturity_date is None:
|
|
|
|
|
last_day = date.fromisoformat(str(daily_output[-1].get("DAY")))
|
|
|
|
|
simulated_days = max(len(daily_output), 1)
|
|
|
|
|
avg_daily_gdd = max((cumulative_gdd - current_gdd) / simulated_days, 0.0)
|
|
|
|
|
remaining_after_simulation = max(required_gdd - cumulative_gdd, 0.0)
|
|
|
|
|
extra_days = 0
|
|
|
|
|
if avg_daily_gdd > 0 and remaining_after_simulation > 0:
|
|
|
|
|
extra_days = int(remaining_after_simulation / avg_daily_gdd)
|
|
|
|
|
if remaining_after_simulation % avg_daily_gdd:
|
|
|
|
|
extra_days += 1
|
|
|
|
|
maturity_date = last_day + timedelta(days=max(extra_days, 0))
|
|
|
|
|
|
|
|
|
|
remaining_gdd = max(required_gdd - current_gdd, 0.0)
|
|
|
|
|
days_until = max((maturity_date - date.today()).days, 0)
|
|
|
|
|
window_start = maturity_date - timedelta(days=3)
|
|
|
|
|
window_end = maturity_date + timedelta(days=3)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"date": maturity_date.isoformat(),
|
|
|
|
|
"dateFormatted": f"{maturity_date.day} {maturity_date.strftime('%B')} {maturity_date.year}",
|
|
|
|
|
"daysUntil": days_until,
|
|
|
|
|
"description": _harvest_description(
|
|
|
|
|
plant_name=context.plant_name,
|
|
|
|
|
current_gdd=current_gdd,
|
|
|
|
|
required_gdd=required_gdd,
|
|
|
|
|
remaining_gdd=remaining_gdd,
|
|
|
|
|
estimated_days=days_until,
|
|
|
|
|
maturity_reached_in_simulation=maturity_reached_in_simulation,
|
|
|
|
|
),
|
|
|
|
|
"optimalWindowStart": window_start.isoformat(),
|
|
|
|
|
"optimalWindowEnd": window_end.isoformat(),
|
|
|
|
|
"gddDetails": {
|
|
|
|
|
"current_cumulative_gdd": round(current_gdd, 3),
|
|
|
|
|
"required_gdd_for_maturity": round(required_gdd, 3),
|
|
|
|
|
"remaining_gdd": round(remaining_gdd, 3),
|
|
|
|
|
"estimated_days_to_harvest": days_until,
|
|
|
|
|
"predicted_harvest_date": maturity_date.isoformat(),
|
|
|
|
|
"predicted_harvest_window": {
|
|
|
|
|
"start": window_start.isoformat(),
|
|
|
|
|
"end": window_end.isoformat(),
|
|
|
|
|
},
|
|
|
|
|
"daily_gdd_forecast": daily_gdd_forecast,
|
|
|
|
|
"simulation_engine": simulation_result.get("engine"),
|
|
|
|
|
"simulation_model_name": simulation_result.get("model_name"),
|
|
|
|
|
"simulation_warning": simulation_warning,
|
|
|
|
|
"scenario_id": scenario_id,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class HarvestPredictionService:
|
|
|
|
|
def get_harvest_prediction(self, *, farm_uuid: str, plant_name: str | None = None) -> dict[str, Any]:
|
|
|
|
|
return build_harvest_prediction_payload(farm_uuid=farm_uuid, plant_name=plant_name)
|