This commit is contained in:
2026-04-24 22:20:15 +03:30
parent f7dc05dc9e
commit 569d520a5c
24 changed files with 687 additions and 152 deletions
+13 -10
View File
@@ -21,17 +21,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
COPY requirements.txt constraints.txt .
# Python mirrors
RUN pip config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple && \
pip config --user set global.extra-index-url https://mirror.cdn.ir/repository/pypi/simple && \
pip config --user set global.extra-index-url https://mirror2.chabokan.net/pypi/simple && \
pip config --user set global.trusted-host package-mirror.liara.ir && \
pip config --user set global.trusted-host mirror.cdn.ir && \
pip config --user set global.trusted-host mirror-pypi.runflare.com
RUN pip install -r requirements.txt
RUN PIP_CONSTRAINT=/app/constraints.txt \
pip install \
--prefer-binary \
--index-url https://mirror-pypi.runflare.com/simple \
--extra-index-url https://package-mirror.liara.ir/repository/pypi/simple \
--extra-index-url https://mirror.cdn.ir/repository/pypi/simple \
--extra-index-url https://mirror2.chabokan.net/pypi/simple \
--trusted-host mirror-pypi.runflare.com \
--trusted-host package-mirror.liara.ir \
--trusted-host mirror.cdn.ir \
--trusted-host mirror2.chabokan.net \
-r requirements.txt
COPY entrypoint.sh /app/entrypoint.sh
COPY . .
+2 -1
View File
@@ -1,5 +1,5 @@
from django.http import HttpResponse
from django.urls import path
from django.urls import include, path
def test_view(_request):
@@ -8,4 +8,5 @@ def test_view(_request):
urlpatterns = [
path("__test__/", test_view),
path("api/rag/", include("rag.urls")),
]
+2
View File
@@ -0,0 +1,2 @@
# Keep build-isolated dependency resolution compatible with Python 3.10.
numpy>=1.23,<1.27
+4 -4
View File
@@ -204,7 +204,7 @@
اگر `pcse` نصب نباشد، `None` برمی‌گرداند.
### `_resolve_model_class(bindings, model_name)`
کلاس مدل PCSE را با نامی مثل `Wofost72_WLP_CWB` پیدا می‌کند.
کلاس مدل PCSE را با نامی مثل `Wofost81_NWLP_CWB_CNB` پیدا می‌کند.
---
@@ -226,13 +226,13 @@
این کلاس فقط مسئول اجرای موتور شبیه‌سازی است و وارد منطق ذخیره سناریوها نمی‌شود.
### `__init__(model_name="Wofost72_WLP_CWB")`
### `__init__(model_name="Wofost81_NWLP_CWB_CNB")`
مدل PCSE مورد استفاده را مشخص می‌کند.
مدل پیش‌فرض:
```python
Wofost72_WLP_CWB
Wofost81_NWLP_CWB_CNB
```
### `run_simulation(...)`
@@ -251,7 +251,7 @@ Wofost72_WLP_CWB
```python
{
"engine": "pcse",
"model_name": "Wofost72_WLP_CWB",
"model_name": "Wofost81_NWLP_CWB_CNB",
"metrics": {...},
"daily_output": [...],
"summary_output": [...],
+35 -1
View File
@@ -12,7 +12,7 @@ from farm_data.models import SensorData
from plant.gdd import calculate_daily_gdd, resolve_growth_profile
from weather.models import WeatherForecast
from .services import CropSimulationService
from .services import CropSimulationService, build_simulation_payload_from_farm
DEFAULT_DYNAMIC_PARAMETERS = ["DVS", "LAI", "TAGP", "TWSO", "SM"]
@@ -35,6 +35,7 @@ class GrowthSimulationError(Exception):
@dataclass
class GrowthSimulationContext:
farm_uuid: str | None
plant_name: str
plant: Any
dynamic_parameters: list[str]
@@ -223,6 +224,7 @@ def build_growth_context(payload: dict[str, Any]) -> GrowthSimulationContext:
page_size = min(max(int(payload.get("page_size") or DEFAULT_PAGE_SIZE), 1), MAX_PAGE_SIZE)
sensor = None
resolved_farm_uuid = str(payload["farm_uuid"]) if payload.get("farm_uuid") else None
if payload.get("farm_uuid"):
sensor = (
SensorData.objects.select_related("center_location")
@@ -233,6 +235,35 @@ def build_growth_context(payload: dict[str, Any]) -> GrowthSimulationContext:
if sensor is None:
raise GrowthSimulationError("Farm not found.")
if resolved_farm_uuid:
farm_payload = build_simulation_payload_from_farm(
farm_uuid=resolved_farm_uuid,
plant_name=plant_name,
weather=payload.get("weather"),
soil=payload.get("soil_parameters"),
crop_parameters=payload.get("crop_parameters"),
agromanagement=payload.get("agromanagement"),
site_parameters=payload.get("site_parameters"),
)
weather = farm_payload["weather"]
crop_parameters = farm_payload["crop_parameters"]
soil_parameters = farm_payload["soil"]
site_parameters = farm_payload["site_parameters"]
agromanagement = farm_payload["agromanagement"]
plant = farm_payload["plant"] or plant
return GrowthSimulationContext(
farm_uuid=resolved_farm_uuid,
plant_name=plant_name,
plant=plant,
dynamic_parameters=dynamic_parameters,
weather=weather,
crop_parameters=crop_parameters,
soil_parameters=soil_parameters,
site_parameters=site_parameters,
agromanagement=agromanagement,
page_size=page_size,
)
weather = (
_normalize_weather_records(payload["weather"])
if payload.get("weather")
@@ -265,6 +296,7 @@ def build_growth_context(payload: dict[str, Any]) -> GrowthSimulationContext:
)
return GrowthSimulationContext(
farm_uuid=resolved_farm_uuid,
plant_name=plant_name,
plant=plant,
dynamic_parameters=dynamic_parameters,
@@ -359,6 +391,8 @@ def _run_projection_engine(context: GrowthSimulationContext) -> dict[str, Any]:
def _run_simulation(context: GrowthSimulationContext) -> tuple[dict[str, Any], int | None, str | None]:
try:
response = CropSimulationService().run_single_simulation(
farm_uuid=context.farm_uuid,
plant_name=context.plant_name,
weather=context.weather,
soil=context.soil_parameters,
crop_parameters=context.crop_parameters,
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("crop_simulation", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="simulationscenario",
name="model_name",
field=models.CharField(default="Wofost81_NWLP_CWB_CNB", max_length=128),
),
]
+1 -1
View File
@@ -23,7 +23,7 @@ class SimulationScenario(models.Model):
default=ScenarioType.SINGLE,
db_index=True,
)
model_name = models.CharField(max_length=128, default="Wofost72_WLP_CWB")
model_name = models.CharField(max_length=128, default="Wofost81_NWLP_CWB_CNB")
status = models.CharField(
max_length=32,
choices=Status.choices,
+6 -2
View File
@@ -368,10 +368,12 @@ class SimulationRecommendationOptimizer:
irrigation_events.append({"date": day, "amount": amount_per_event})
try:
result = self.simulation_service.run_single_simulation(
farm_uuid=str(sensor.farm_uuid),
plant_name=getattr(plant, "name", None),
weather=weather,
soil=soil,
crop_parameters=crop_parameters,
agromanagement=agromanagement,
soil=soil,
site_parameters=site,
irrigation_recommendation={"events": irrigation_events},
name=f"irrigation-{spec['code']}",
@@ -605,10 +607,12 @@ class SimulationRecommendationOptimizer:
try:
result = self.simulation_service.run_single_simulation(
farm_uuid=str(sensor.farm_uuid),
plant_name=getattr(plant, "name", None),
weather=weather,
soil=soil,
crop_parameters=crop_parameters,
agromanagement=strategy_agromanagement,
soil=soil,
site_parameters=site,
name=f"fertilization-{spec['code']}",
)
+518 -72
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import importlib
from copy import deepcopy
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any
@@ -13,6 +14,9 @@ from .models import SimulationRun, SimulationScenario
DEFAULT_OUTPUT_VARS = ["DVS", "LAI", "TAGP", "TWSO", "SM"]
DEFAULT_SUMMARY_VARS = ["TAGP", "TWSO", "CTRAT", "RD"]
DEFAULT_TERMINAL_VARS = ["TAGP", "TWSO", "LAI", "DVS"]
DEFAULT_PCSE_MODEL_NAME = "Wofost81_NWLP_CWB_CNB"
DEFAULT_NAVAILI = 35.0
DEFAULT_WAV = 40.0
class CropSimulationError(Exception):
@@ -229,6 +233,265 @@ def _pick_first_not_none(*values: Any) -> Any:
return None
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 _clamp(value: float, lower: float, upper: float) -> float:
return max(lower, min(value, upper))
def _sensor_metric(sensor: Any, metric_name: str) -> float | None:
if sensor is None:
return None
if hasattr(sensor, metric_name):
value = getattr(sensor, metric_name)
if value is not None:
return _safe_float(value)
payload = getattr(sensor, "sensor_payload", None) or {}
if not isinstance(payload, dict):
return None
for block in payload.values():
if isinstance(block, dict) and block.get(metric_name) is not None:
return _safe_float(block.get(metric_name))
return None
def _extract_plant_simulation_profile(plant: Any | None) -> dict[str, Any] | None:
if plant is None:
return None
for attr in ("growth_profile", "irrigation_profile", "health_profile"):
profile = getattr(plant, attr, None) or {}
if not isinstance(profile, dict):
continue
simulation = profile.get("simulation")
if isinstance(simulation, dict):
return simulation
return None
def _build_default_crop_parameters(plant: Any | None, crop_name: str) -> dict[str, Any]:
profile = getattr(plant, "growth_profile", None) or {}
required_gdd = _safe_float(profile.get("required_gdd_for_maturity"), 1200.0)
return {
"crop_name": crop_name,
"TSUM1": round(required_gdd * 0.45, 3),
"TSUM2": round(required_gdd * 0.55, 3),
"YIELD_SCALE": 1.0,
"MAX_LAI": 5.0,
"MAX_BIOMASS": 12000.0,
}
def _build_default_agromanagement(crop_name: str, weather: list[dict[str, Any]]) -> 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 [
{
first_day: {
"CropCalendar": {
"crop_name": crop_name,
"variety_name": "default",
"crop_start_date": first_day,
"crop_start_type": "sowing",
"crop_end_date": crop_end,
"crop_end_type": "harvest",
"max_duration": max((crop_end - first_day).days, 1),
},
"TimedEvents": [],
"StateEvents": [],
}
}
]
def _build_weather_from_forecasts(forecasts: list[Any], *, latitude: float, longitude: float) -> list[dict[str, Any]]:
return [
{
"DAY": forecast.forecast_date,
"LAT": latitude,
"LON": longitude,
"ELEV": 1200.0,
"IRRAD": 16_000_000.0,
"TMIN": _safe_float(
_pick_first_not_none(forecast.temperature_min, forecast.temperature_mean),
12.0,
),
"TMAX": _safe_float(
_pick_first_not_none(forecast.temperature_max, forecast.temperature_mean),
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),
}
for forecast in forecasts
]
def _normalize_site_parameters_for_model(
model_name: str,
site_parameters: dict[str, Any] | None,
*,
soil_parameters: dict[str, Any] | None = None,
) -> dict[str, Any]:
site = dict(site_parameters or {})
soil = soil_parameters or {}
site.setdefault("WAV", _safe_float(site.get("WAV"), DEFAULT_WAV))
if model_name.startswith("Wofost81_NWLP"):
navaili = _pick_first_not_none(
site.get("NAVAILI"),
site.get("navaili"),
site.get("nitrogen"),
soil.get("NAVAILI"),
soil.get("nitrogen"),
)
site["NAVAILI"] = _safe_float(navaili, DEFAULT_NAVAILI)
site.setdefault("BG_N_SUPPLY", 0.05)
site.setdefault("NSOILBASE", max(site["NAVAILI"] * 0.35, 5.0))
site.setdefault("NSOILBASE_FR", 0.02)
return site
def build_simulation_payload_from_farm(
*,
farm_uuid: str,
plant_name: str | None = None,
weather: Any | None = None,
soil: dict[str, Any] | None = None,
crop_parameters: dict[str, Any] | None = None,
agromanagement: Any | None = None,
site_parameters: dict[str, Any] | None = None,
) -> dict[str, Any]:
from farm_data.models import SensorData
from weather.models import WeatherForecast
farm = (
SensorData.objects.select_related("center_location", "irrigation_method")
.prefetch_related("plants", "center_location__depths")
.filter(farm_uuid=farm_uuid)
.first()
)
if farm is None:
raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.")
plant = None
if plant_name:
plant = farm.plants.filter(name=plant_name).first()
if plant is None:
plant = farm.plants.first()
if weather is not None:
resolved_weather = _normalize_weather_records(weather)
else:
forecasts = list(
WeatherForecast.objects.filter(location=farm.center_location)
.order_by("forecast_date")[:14]
)
if not forecasts:
raise CropSimulationError(
"Weather data for the selected farm is missing."
)
resolved_weather = _build_weather_from_forecasts(
forecasts,
latitude=float(farm.center_location.latitude),
longitude=float(farm.center_location.longitude),
)
depths = list(farm.center_location.depths.all())
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))
soil_moisture = _sensor_metric(farm, "soil_moisture")
wav = (
round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3)
if soil_moisture is not None
else DEFAULT_WAV
)
nitrogen = _pick_first_not_none(_sensor_metric(farm, "nitrogen"), getattr(top_depth, "nitrogen", None))
phosphorus = _sensor_metric(farm, "phosphorus")
potassium = _sensor_metric(farm, "potassium")
soil_ph = _pick_first_not_none(_sensor_metric(farm, "soil_ph"), getattr(top_depth, "phh2o", None))
ec = _sensor_metric(farm, "electrical_conductivity")
resolved_soil = {
"SMFCF": round(smfcf, 3),
"SMW": round(smw, 3),
"RDMSOL": 120.0,
"soil_moisture": soil_moisture,
"nitrogen": _safe_float(nitrogen, DEFAULT_NAVAILI),
"phosphorus": _safe_float(phosphorus, 0.0),
"potassium": _safe_float(potassium, 0.0),
"soil_ph": _safe_float(soil_ph, 7.0),
"electrical_conductivity": _safe_float(ec, 0.0),
"clay": _safe_float(getattr(top_depth, "clay", None), 0.0),
"sand": _safe_float(getattr(top_depth, "sand", None), 0.0),
"silt": _safe_float(getattr(top_depth, "silt", None), 0.0),
"cec": _safe_float(getattr(top_depth, "cec", None), 0.0),
"soc": _safe_float(getattr(top_depth, "soc", None), 0.0),
}
if soil:
resolved_soil.update(soil)
resolved_site = {
"WAV": wav,
"NAVAILI": _safe_float(nitrogen, DEFAULT_NAVAILI),
"P_STATUS": _safe_float(phosphorus, 0.0),
"K_STATUS": _safe_float(potassium, 0.0),
"SOIL_PH": _safe_float(soil_ph, 7.0),
"EC": _safe_float(ec, 0.0),
}
if site_parameters:
resolved_site.update(site_parameters)
simulation_profile = _extract_plant_simulation_profile(plant)
default_crop = (
deepcopy(simulation_profile.get("crop_parameters"))
if simulation_profile and isinstance(simulation_profile.get("crop_parameters"), dict)
else _build_default_crop_parameters(plant, plant_name or getattr(plant, "name", "crop"))
)
resolved_crop = default_crop
if crop_parameters:
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))
default_agromanagement = (
deepcopy(simulation_profile.get("agromanagement"))
if simulation_profile and simulation_profile.get("agromanagement")
else _build_default_agromanagement(resolved_crop["crop_name"], resolved_weather)
)
resolved_agromanagement = agromanagement if agromanagement is not None else default_agromanagement
return {
"farm": farm,
"plant": plant,
"weather": resolved_weather,
"soil": resolved_soil,
"site_parameters": resolved_site,
"crop_parameters": resolved_crop,
"agromanagement": resolved_agromanagement,
}
def _extract_total_n(agromanagement: list[dict[str, Any]]) -> float:
total_n = 0.0
for campaign in agromanagement:
@@ -250,6 +513,73 @@ def _extract_total_n(agromanagement: list[dict[str, Any]]) -> float:
return total_n
def _estimate_pk_stress_factor(
*,
soil: dict[str, Any],
site: dict[str, Any],
crop: dict[str, Any],
) -> dict[str, float]:
phosphorus = _safe_float(
_pick_first_not_none(site.get("P_STATUS"), soil.get("phosphorus"), crop.get("soil_phosphorus")),
0.0,
)
potassium = _safe_float(
_pick_first_not_none(site.get("K_STATUS"), soil.get("potassium"), crop.get("soil_potassium")),
0.0,
)
soil_ph = _safe_float(
_pick_first_not_none(site.get("SOIL_PH"), soil.get("soil_ph"), crop.get("soil_ph")),
7.0,
)
ec = _safe_float(_pick_first_not_none(site.get("EC"), soil.get("electrical_conductivity")), 0.0)
phosphorus_target = _safe_float(crop.get("P_OPTIMAL"), 30.0)
potassium_target = _safe_float(crop.get("K_OPTIMAL"), 45.0)
p_factor = _clamp(phosphorus / max(phosphorus_target, 1.0), 0.45, 1.0)
k_factor = _clamp(potassium / max(potassium_target, 1.0), 0.45, 1.0)
ph_penalty = 1.0
if soil_ph < 5.8:
ph_penalty = _clamp(1.0 - ((5.8 - soil_ph) * 0.08), 0.65, 1.0)
elif soil_ph > 7.8:
ph_penalty = _clamp(1.0 - ((soil_ph - 7.8) * 0.06), 0.7, 1.0)
ec_penalty = 1.0
if ec > 2.5:
ec_penalty = _clamp(1.0 - ((ec - 2.5) * 0.07), 0.72, 1.0)
combined_factor = round(_clamp(p_factor * k_factor * ph_penalty * ec_penalty, 0.35, 1.0), 4)
return {
"phosphorus_factor": round(p_factor, 4),
"potassium_factor": round(k_factor, 4),
"ph_penalty": round(ph_penalty, 4),
"ec_penalty": round(ec_penalty, 4),
"combined_factor": combined_factor,
}
def _apply_pk_adjustment(
result: dict[str, Any],
*,
soil: dict[str, Any],
site: dict[str, Any],
crop: dict[str, Any],
) -> dict[str, Any]:
adjustment = _estimate_pk_stress_factor(soil=soil, site=site, crop=crop)
factor = adjustment["combined_factor"]
if factor >= 0.995:
result["nutrient_adjustment"] = adjustment
return result
metrics = dict(result.get("metrics", {}))
for key, scale in {"yield_estimate": factor, "biomass": factor, "max_lai": max(factor, 0.6)}.items():
if metrics.get(key) is not None:
metrics[key] = round(_safe_float(metrics[key]) * scale, 4)
result["metrics"] = metrics
result["nutrient_adjustment"] = adjustment
return result
def _load_pcse_bindings() -> dict[str, Any] | None:
try:
base_module = importlib.import_module("pcse.base")
@@ -288,7 +618,7 @@ class PreparedSimulationInput:
class PcseSimulationManager:
def __init__(self, model_name: str = "Wofost72_WLP_CWB"):
def __init__(self, model_name: str = DEFAULT_PCSE_MODEL_NAME):
self.model_name = model_name
def run_simulation(
@@ -304,7 +634,11 @@ class PcseSimulationManager:
weather=_normalize_weather_records(weather),
soil=soil or {},
crop=crop_parameters or {},
site=site_parameters or {},
site=_normalize_site_parameters_for_model(
self.model_name,
site_parameters or {},
soil_parameters=soil or {},
),
agromanagement=_normalize_agromanagement(agromanagement),
)
bindings = _load_pcse_bindings()
@@ -312,7 +646,15 @@ class PcseSimulationManager:
raise CropSimulationError(
"PCSE is not installed or required PCSE classes could not be loaded."
)
return self._run_with_pcse(prepared, bindings)
result = self._run_with_pcse(prepared, bindings)
if self.model_name.startswith("Wofost81_NWLP"):
result = _apply_pk_adjustment(
result,
soil=prepared.soil,
site=prepared.site,
crop=prepared.crop,
)
return result
def _run_with_pcse(
self,
@@ -407,20 +749,73 @@ class CropSimulationService:
def __init__(self, manager: PcseSimulationManager | None = None):
self.manager = manager or PcseSimulationManager()
def _resolve_common_inputs(
self,
*,
farm_uuid: str | None = None,
plant_name: str | None = None,
weather: Any | None = None,
soil: dict[str, Any] | None = None,
crop_parameters: dict[str, Any] | None = None,
agromanagement: Any | None = None,
site_parameters: dict[str, Any] | None = None,
) -> dict[str, Any]:
if not farm_uuid:
return {
"weather": weather,
"soil": soil or {},
"crop_parameters": crop_parameters or {},
"agromanagement": agromanagement,
"site_parameters": _normalize_site_parameters_for_model(
self.manager.model_name,
site_parameters or {},
soil_parameters=soil or {},
),
"farm": None,
"plant": None,
}
base = build_simulation_payload_from_farm(
farm_uuid=str(farm_uuid),
plant_name=plant_name or (crop_parameters or {}).get("crop_name"),
weather=weather,
soil=soil,
crop_parameters=crop_parameters,
agromanagement=agromanagement,
site_parameters=site_parameters,
)
base["site_parameters"] = _normalize_site_parameters_for_model(
self.manager.model_name,
base.get("site_parameters"),
soil_parameters=base.get("soil"),
)
return base
def run_single_simulation(
self,
*,
weather: Any,
soil: dict[str, Any],
crop_parameters: dict[str, Any],
agromanagement: Any,
weather: Any | None = None,
soil: dict[str, Any] | None = None,
crop_parameters: dict[str, Any] | None = None,
agromanagement: Any | None = None,
site_parameters: dict[str, Any] | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
name: str = "",
farm_uuid: str | None = None,
plant_name: str | None = None,
) -> dict[str, Any]:
resolved = self._resolve_common_inputs(
farm_uuid=farm_uuid,
plant_name=plant_name,
weather=weather,
soil=soil,
crop_parameters=crop_parameters,
agromanagement=agromanagement,
site_parameters=site_parameters,
)
merged_agromanagement = _merge_management_recommendations(
agromanagement,
resolved["agromanagement"],
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
@@ -430,13 +825,15 @@ class CropSimulationService:
model_name=self.manager.model_name,
input_payload=_json_ready(
{
"weather": weather,
"soil": soil,
"crop_parameters": crop_parameters,
"site_parameters": site_parameters or {},
"weather": resolved["weather"],
"soil": resolved["soil"],
"crop_parameters": resolved["crop_parameters"],
"site_parameters": resolved["site_parameters"],
"agromanagement": merged_agromanagement,
"irrigation_recommendation": irrigation_recommendation or {},
"fertilization_recommendation": fertilization_recommendation or {},
"farm_uuid": farm_uuid,
"plant_name": plant_name,
}
),
)
@@ -444,10 +841,10 @@ class CropSimulationService:
scenario=scenario,
run_key="single",
label=name or "single",
weather_payload=_json_ready(weather),
soil_payload=_json_ready(soil),
crop_payload=_json_ready(crop_parameters),
site_payload=_json_ready(site_parameters or {}),
weather_payload=_json_ready(resolved["weather"]),
soil_payload=_json_ready(resolved["soil"]),
crop_payload=_json_ready(resolved["crop_parameters"]),
site_payload=_json_ready(resolved["site_parameters"]),
agromanagement_payload=_json_ready(merged_agromanagement),
)
return self._execute_scenario(
@@ -455,10 +852,10 @@ class CropSimulationService:
run_specs=[
{
"instance": run,
"weather": weather,
"soil": soil,
"crop_parameters": crop_parameters,
"site_parameters": site_parameters or {},
"weather": resolved["weather"],
"soil": resolved["soil"],
"crop_parameters": resolved["crop_parameters"],
"site_parameters": resolved["site_parameters"],
"agromanagement": merged_agromanagement,
}
],
@@ -467,18 +864,27 @@ class CropSimulationService:
def compare_crops(
self,
*,
weather: Any,
soil: dict[str, Any],
weather: Any | None = None,
soil: dict[str, Any] | None = None,
crop_a: dict[str, Any],
crop_b: dict[str, Any],
agromanagement: Any,
agromanagement: Any | None = None,
site_parameters: dict[str, Any] | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
name: str = "",
farm_uuid: str | None = None,
) -> dict[str, Any]:
resolved = self._resolve_common_inputs(
farm_uuid=farm_uuid,
weather=weather,
soil=soil,
crop_parameters=None,
agromanagement=agromanagement,
site_parameters=site_parameters,
)
merged_agromanagement = _merge_management_recommendations(
agromanagement,
resolved["agromanagement"],
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
@@ -488,14 +894,15 @@ class CropSimulationService:
model_name=self.manager.model_name,
input_payload=_json_ready(
{
"weather": weather,
"soil": soil,
"weather": resolved["weather"],
"soil": resolved["soil"],
"crop_a": crop_a,
"crop_b": crop_b,
"site_parameters": site_parameters or {},
"site_parameters": resolved["site_parameters"],
"agromanagement": merged_agromanagement,
"irrigation_recommendation": irrigation_recommendation or {},
"fertilization_recommendation": fertilization_recommendation or {},
"farm_uuid": farm_uuid,
}
),
)
@@ -504,20 +911,20 @@ class CropSimulationService:
scenario=scenario,
run_key="crop_a",
label=crop_a.get("crop_name", "crop_a"),
weather_payload=_json_ready(weather),
soil_payload=_json_ready(soil),
weather_payload=_json_ready(resolved["weather"]),
soil_payload=_json_ready(resolved["soil"]),
crop_payload=_json_ready(crop_a),
site_payload=_json_ready(site_parameters or {}),
site_payload=_json_ready(resolved["site_parameters"]),
agromanagement_payload=_json_ready(merged_agromanagement),
),
SimulationRun.objects.create(
scenario=scenario,
run_key="crop_b",
label=crop_b.get("crop_name", "crop_b"),
weather_payload=_json_ready(weather),
soil_payload=_json_ready(soil),
weather_payload=_json_ready(resolved["weather"]),
soil_payload=_json_ready(resolved["soil"]),
crop_payload=_json_ready(crop_b),
site_payload=_json_ready(site_parameters or {}),
site_payload=_json_ready(resolved["site_parameters"]),
agromanagement_payload=_json_ready(merged_agromanagement),
),
]
@@ -526,18 +933,18 @@ class CropSimulationService:
run_specs=[
{
"instance": runs[0],
"weather": weather,
"soil": soil,
"weather": resolved["weather"],
"soil": resolved["soil"],
"crop_parameters": crop_a,
"site_parameters": site_parameters or {},
"site_parameters": resolved["site_parameters"],
"agromanagement": merged_agromanagement,
},
{
"instance": runs[1],
"weather": weather,
"soil": soil,
"weather": resolved["weather"],
"soil": resolved["soil"],
"crop_parameters": crop_b,
"site_parameters": site_parameters or {},
"site_parameters": resolved["site_parameters"],
"agromanagement": merged_agromanagement,
},
],
@@ -546,20 +953,44 @@ class CropSimulationService:
def recommend_best_crop(
self,
*,
weather: Any,
soil: dict[str, Any],
crops: list[dict[str, Any]],
agromanagement: Any,
weather: Any | None = None,
soil: dict[str, Any] | None = None,
crops: list[dict[str, Any]] | None = None,
agromanagement: Any | None = None,
site_parameters: dict[str, Any] | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
name: str = "",
farm_uuid: str | None = None,
) -> dict[str, Any]:
if not crops and farm_uuid:
base = build_simulation_payload_from_farm(farm_uuid=str(farm_uuid))
crops = []
for plant in base["farm"].plants.all():
simulation_profile = _extract_plant_simulation_profile(plant)
crop_payload = (
deepcopy(simulation_profile.get("crop_parameters"))
if simulation_profile and isinstance(simulation_profile.get("crop_parameters"), dict)
else _build_default_crop_parameters(plant, plant.name)
)
crop_payload.setdefault("crop_name", plant.name)
crop_payload.setdefault("label", plant.name)
crops.append(crop_payload)
crops = crops or []
if len(crops) < 2:
raise CropSimulationError("At least two crop options are required.")
resolved = self._resolve_common_inputs(
farm_uuid=farm_uuid,
weather=weather,
soil=soil,
crop_parameters=None,
agromanagement=agromanagement,
site_parameters=site_parameters,
)
merged_agromanagement = _merge_management_recommendations(
agromanagement,
resolved["agromanagement"],
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
@@ -570,13 +1001,14 @@ class CropSimulationService:
model_name=self.manager.model_name,
input_payload=_json_ready(
{
"weather": weather,
"soil": soil,
"weather": resolved["weather"],
"soil": resolved["soil"],
"crops": crops,
"site_parameters": site_parameters or {},
"site_parameters": resolved["site_parameters"],
"agromanagement": merged_agromanagement,
"irrigation_recommendation": irrigation_recommendation or {},
"fertilization_recommendation": fertilization_recommendation or {},
"farm_uuid": farm_uuid,
}
),
)
@@ -593,19 +1025,19 @@ class CropSimulationService:
scenario=scenario,
run_key=f"crop_{index}",
label=label,
weather_payload=_json_ready(weather),
soil_payload=_json_ready(soil),
weather_payload=_json_ready(resolved["weather"]),
soil_payload=_json_ready(resolved["soil"]),
crop_payload=_json_ready(crop),
site_payload=_json_ready(site_parameters or {}),
site_payload=_json_ready(resolved["site_parameters"]),
agromanagement_payload=_json_ready(merged_agromanagement),
)
run_specs.append(
{
"instance": run,
"weather": weather,
"soil": soil,
"weather": resolved["weather"],
"soil": resolved["soil"],
"crop_parameters": crop,
"site_parameters": site_parameters or {},
"site_parameters": resolved["site_parameters"],
"agromanagement": merged_agromanagement,
}
)
@@ -627,31 +1059,44 @@ class CropSimulationService:
def compare_fertilization_strategies(
self,
*,
weather: Any,
soil: dict[str, Any],
crop_parameters: dict[str, Any],
weather: Any | None = None,
soil: dict[str, Any] | None = None,
crop_parameters: dict[str, Any] | None = None,
strategies: list[dict[str, Any]],
site_parameters: dict[str, Any] | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
name: str = "",
farm_uuid: str | None = None,
plant_name: str | None = None,
) -> dict[str, Any]:
if len(strategies) < 2:
raise CropSimulationError("At least two fertilization strategies are required.")
resolved = self._resolve_common_inputs(
farm_uuid=farm_uuid,
plant_name=plant_name,
weather=weather,
soil=soil,
crop_parameters=crop_parameters,
agromanagement=None,
site_parameters=site_parameters,
)
scenario = SimulationScenario.objects.create(
name=name,
scenario_type=SimulationScenario.ScenarioType.FERTILIZATION_COMPARISON,
model_name=self.manager.model_name,
input_payload=_json_ready(
{
"weather": weather,
"soil": soil,
"crop_parameters": crop_parameters,
"site_parameters": site_parameters or {},
"weather": resolved["weather"],
"soil": resolved["soil"],
"crop_parameters": resolved["crop_parameters"],
"site_parameters": resolved["site_parameters"],
"strategies": strategies,
"irrigation_recommendation": irrigation_recommendation or {},
"fertilization_recommendation": fertilization_recommendation or {},
"farm_uuid": farm_uuid,
"plant_name": plant_name,
}
),
)
@@ -666,19 +1111,19 @@ class CropSimulationService:
scenario=scenario,
run_key=f"strategy_{index}",
label=strategy.get("label", f"strategy_{index}"),
weather_payload=_json_ready(weather),
soil_payload=_json_ready(soil),
crop_payload=_json_ready(crop_parameters),
site_payload=_json_ready(site_parameters or {}),
weather_payload=_json_ready(resolved["weather"]),
soil_payload=_json_ready(resolved["soil"]),
crop_payload=_json_ready(resolved["crop_parameters"]),
site_payload=_json_ready(resolved["site_parameters"]),
agromanagement_payload=_json_ready(merged_agromanagement),
)
run_specs.append(
{
"instance": run,
"weather": weather,
"soil": soil,
"crop_parameters": crop_parameters,
"site_parameters": site_parameters or {},
"weather": resolved["weather"],
"soil": resolved["soil"],
"crop_parameters": resolved["crop_parameters"],
"site_parameters": resolved["site_parameters"],
"agromanagement": merged_agromanagement,
}
)
@@ -797,10 +1242,11 @@ class CropSimulationService:
"runs": run_metrics,
}
if scenario.scenario_type == SimulationScenario.ScenarioType.CROP_COMPARISON:
payload["comparison"]["yield_gap"] = round(
abs(run_metrics[0]["yield_estimate"] - run_metrics[1]["yield_estimate"]),
3,
)
if len(run_metrics) >= 2:
payload["comparison"]["yield_gap"] = round(
abs(run_metrics[0]["yield_estimate"] - run_metrics[1]["yield_estimate"]),
3,
)
if (
scenario.scenario_type
== SimulationScenario.ScenarioType.FERTILIZATION_COMPARISON
+3 -3
View File
@@ -117,7 +117,7 @@ class CropSimulationServiceTests(TestCase):
side_effect=[
{
"engine": "pcse",
"model_name": "Wofost72_WLP_CWB",
"model_name": "Wofost81_NWLP_CWB_CNB",
"metrics": {
"yield_estimate": 5200.0,
"biomass": 9800.0,
@@ -129,7 +129,7 @@ class CropSimulationServiceTests(TestCase):
},
{
"engine": "pcse",
"model_name": "Wofost72_WLP_CWB",
"model_name": "Wofost81_NWLP_CWB_CNB",
"metrics": {
"yield_estimate": 6100.0,
"biomass": 11000.0,
@@ -174,7 +174,7 @@ class CropSimulationServiceTests(TestCase):
captured.update(kwargs)
return {
"engine": "pcse",
"model_name": "Wofost72_WLP_CWB",
"model_name": "Wofost81_NWLP_CWB_CNB",
"metrics": {
"yield_estimate": 5400.0,
"biomass": 9800.0,
+1
View File
@@ -12,6 +12,7 @@ class FertilizationConfig(AppConfig):
@cached_property
def optimizer_defaults(self):
return {
"simulation_model": "Wofost81_NWLP_CWB_CNB",
"validity_days": 7,
"rain_delay_threshold_mm": 3.0,
"stage_targets": {
+9 -1
View File
@@ -4,11 +4,19 @@ from rest_framework import serializers
class FertilizationRecommendRequestSerializer(serializers.Serializer):
"""سریالایزر ورودی برای درخواست توصیه کودهی."""
sensor_uuid = serializers.CharField(help_text="شناسه یکتای سنسور (اجباری)")
farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه (اجباری)")
sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه")
query = serializers.CharField(required=False, allow_blank=True, help_text="سوال اختیاری")
def validate(self, attrs):
farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid")
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."})
attrs["farm_uuid"] = farm_uuid
return attrs
class FertilizationPlanSerializer(serializers.Serializer):
"""سریالایزر خروجی برای پلن توصیه کودهی."""
+4 -4
View File
@@ -29,7 +29,7 @@ FertilizationResponseSerializer = build_envelope_serializer(
class FertilizationRecommendView(APIView):
"""
توصیه کودهی به صورت مستقیم.
POST با sensor_uuid، plant_name، growth_stage.
POST با farm_uuid، plant_name، growth_stage.
اطلاعات گیاه از plant app دریافت می‌شود.
نیازی به دریافت نوع آبیاری نیست.
"""
@@ -62,7 +62,7 @@ class FertilizationRecommendView(APIView):
OpenApiExample(
"نمونه درخواست",
value={
"sensor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": "گوجه‌فرنگی",
"growth_stage": "گلدهی",
},
@@ -81,14 +81,14 @@ class FertilizationRecommendView(APIView):
)
validated = serializer.validated_data
sensor_uuid = validated["sensor_uuid"]
farm_uuid = validated["farm_uuid"]
plant_name = validated.get("plant_name")
growth_stage = validated.get("growth_stage")
query = validated.get("query")
try:
result = get_fertilization_recommendation(
sensor_uuid=sensor_uuid,
farm_uuid=farm_uuid,
plant_name=plant_name,
growth_stage=growth_stage,
query=query,
+1
View File
@@ -12,6 +12,7 @@ class IrrigationConfig(AppConfig):
@cached_property
def optimizer_defaults(self):
return {
"simulation_model": "Wofost81_NWLP_CWB_CNB",
"validity_days": 3,
"minimum_event_mm": 4.0,
"significant_rain_threshold_mm": 4.0,
+9 -1
View File
@@ -28,7 +28,8 @@ class IrrigationMethodSerializer(serializers.ModelSerializer):
class IrrigationRecommendRequestSerializer(serializers.Serializer):
"""سریالایزر ورودی برای درخواست توصیه آبیاری."""
sensor_uuid = serializers.CharField(help_text="شناسه یکتای سنسور (اجباری)")
farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه (اجباری)")
sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه")
irrigation_method_name = serializers.CharField(
@@ -36,6 +37,13 @@ class IrrigationRecommendRequestSerializer(serializers.Serializer):
)
query = serializers.CharField(required=False, allow_blank=True, help_text="سوال اختیاری")
def validate(self, attrs):
farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid")
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."})
attrs["farm_uuid"] = farm_uuid
return attrs
class IrrigationPlanSerializer(serializers.Serializer):
"""سریالایزر خروجی برای پلن توصیه آبیاری."""
+4 -4
View File
@@ -107,7 +107,7 @@ class IrrigationMethodListCreateView(APIView):
class IrrigationRecommendView(APIView):
"""
توصیه آبیاری به صورت مستقیم.
POST با sensor_uuid، plant_name، growth_stage، irrigation_method_name.
POST با farm_uuid، plant_name، growth_stage، irrigation_method_name.
اطلاعات گیاه از plant app و روش آبیاری از irrigation app دریافت می‌شود.
"""
@@ -139,7 +139,7 @@ class IrrigationRecommendView(APIView):
OpenApiExample(
"نمونه درخواست",
value={
"sensor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": "گوجه‌فرنگی",
"growth_stage": "گلدهی",
"irrigation_method_name": "آبیاری قطره‌ای",
@@ -159,7 +159,7 @@ class IrrigationRecommendView(APIView):
)
validated = serializer.validated_data
sensor_uuid = validated["sensor_uuid"]
farm_uuid = validated["farm_uuid"]
plant_name = validated.get("plant_name")
growth_stage = validated.get("growth_stage")
irrigation_method_name = validated.get("irrigation_method_name")
@@ -167,7 +167,7 @@ class IrrigationRecommendView(APIView):
try:
result = get_irrigation_recommendation(
sensor_uuid=sensor_uuid,
farm_uuid=farm_uuid,
plant_name=plant_name,
growth_stage=growth_stage,
irrigation_method_name=irrigation_method_name,
+13 -8
View File
@@ -173,19 +173,20 @@ def _merge_fertilization_response(
def get_fertilization_recommendation(
sensor_uuid: str,
farm_uuid: str | None = None,
plant_name: str | None = None,
growth_stage: str | None = None,
query: str | None = None,
config: RAGConfig | None = None,
limit: int = 8,
sensor_uuid: str | None = None,
) -> dict:
"""
توصیه کودهی برای یک سنسور (کاربر).
توصیه کودهی برای یک مزرعه.
از RAG با پایگاه دانش fertilization استفاده می‌کند.
Args:
sensor_uuid: شناسه سنسور کاربر
farm_uuid: شناسه مزرعه
plant_name: نام گیاه (برای بارگذاری مشخصات از جدول Plant)
growth_stage: مرحله رشد گیاه
query: سوال اختیاری
@@ -193,7 +194,7 @@ def get_fertilization_recommendation(
limit: تعداد چانک‌های بازیابی‌شده
Returns:
dict با کلیدهای fertilizer_needed, fertilizer_type, amount_kg_per_hectare, reason, npk_status, raw_response
dict ساختاریافته برای توصیه کودهی
"""
cfg = config or load_rag_config()
service = get_service_config(SERVICE_ID, cfg)
@@ -209,12 +210,16 @@ def get_fertilization_recommendation(
client = get_chat_client(service_cfg)
model = service.llm.model
resolved_farm_uuid = str(farm_uuid or sensor_uuid or "").strip()
if not resolved_farm_uuid:
raise ValueError("farm_uuid is required.")
user_query = query or "توصیه کودهی برای مزرعه من چیست؟"
sensor = (
SensorData.objects.select_related("center_location")
.prefetch_related("plants")
.filter(farm_uuid=sensor_uuid)
.filter(farm_uuid=resolved_farm_uuid)
.first()
)
resolved_plant_name = plant_name
@@ -246,7 +251,7 @@ def get_fertilization_recommendation(
)
context = build_rag_context(
user_query, sensor_uuid, config=cfg, limit=limit, kb_name=KB_NAME, service_id=SERVICE_ID,
user_query, resolved_farm_uuid, config=cfg, limit=limit, kb_name=KB_NAME, service_id=SERVICE_ID,
)
extra_parts: list[str] = []
@@ -276,7 +281,7 @@ def get_fertilization_recommendation(
{"role": "user", "content": user_query},
]
audit_log = _create_audit_log(
farm_uuid=sensor_uuid,
farm_uuid=resolved_farm_uuid,
service_id=SERVICE_ID,
model=model,
query=user_query,
@@ -291,7 +296,7 @@ def get_fertilization_recommendation(
)
raw = response.choices[0].message.content.strip()
except Exception as exc:
logger.error("Fertilization recommendation error for %s: %s", sensor_uuid, exc)
logger.error("Fertilization recommendation error for %s: %s", resolved_farm_uuid, exc)
result = _build_fertilization_fallback(optimized_result=optimized_result)
result["error"] = f"خطا در دریافت توصیه: {exc}"
result["raw_response"] = None
+13 -8
View File
@@ -219,20 +219,21 @@ def _persist_irrigation_method_on_farm(
def get_irrigation_recommendation(
sensor_uuid: str,
farm_uuid: str | None = None,
plant_name: str | None = None,
growth_stage: str | None = None,
irrigation_method_name: str | None = None,
query: str | None = None,
config: RAGConfig | None = None,
limit: int = 8,
sensor_uuid: str | None = None,
) -> dict:
"""
توصیه آبیاری برای یک سنسور (کاربر).
توصیه آبیاری برای یک مزرعه.
از RAG با پایگاه دانش irrigation استفاده می‌کند.
Args:
sensor_uuid: شناسه سنسور کاربر
farm_uuid: شناسه مزرعه
plant_name: نام گیاه (برای بارگذاری مشخصات از جدول Plant)
growth_stage: مرحله رشد گیاه
irrigation_method_name: نام روش آبیاری (برای بارگذاری از جدول IrrigationMethod)
@@ -241,7 +242,7 @@ def get_irrigation_recommendation(
limit: تعداد چانک‌های بازیابی‌شده
Returns:
dict با کلیدهای irrigation_needed, amount_mm, reason, next_check_date, raw_response
dict ساختاریافته برای توصیه آبیاری
"""
cfg = config or load_rag_config()
service = get_service_config(SERVICE_ID, cfg)
@@ -257,12 +258,16 @@ def get_irrigation_recommendation(
client = get_chat_client(service_cfg)
model = service.llm.model
resolved_farm_uuid = str(farm_uuid or sensor_uuid or "").strip()
if not resolved_farm_uuid:
raise ValueError("farm_uuid is required.")
user_query = query or "توصیه آبیاری برای مزرعه من چیست؟"
sensor = (
SensorData.objects.select_related("center_location", "irrigation_method")
.prefetch_related("plants")
.filter(farm_uuid=sensor_uuid)
.filter(farm_uuid=resolved_farm_uuid)
.first()
)
irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name)
@@ -309,7 +314,7 @@ def get_irrigation_recommendation(
)
context = build_rag_context(
user_query, sensor_uuid, config=cfg, limit=limit, kb_name=KB_NAME, service_id=SERVICE_ID,
user_query, resolved_farm_uuid, config=cfg, limit=limit, kb_name=KB_NAME, service_id=SERVICE_ID,
)
extra_parts: list[str] = []
@@ -360,7 +365,7 @@ def get_irrigation_recommendation(
{"role": "user", "content": user_query},
]
audit_log = _create_audit_log(
farm_uuid=sensor_uuid,
farm_uuid=resolved_farm_uuid,
service_id=SERVICE_ID,
model=model,
query=user_query,
@@ -375,7 +380,7 @@ def get_irrigation_recommendation(
)
raw = response.choices[0].message.content.strip()
except Exception as exc:
logger.error("Irrigation recommendation error for %s: %s", sensor_uuid, exc)
logger.error("Irrigation recommendation error for %s: %s", resolved_farm_uuid, exc)
result = _build_irrigation_fallback(
optimized_result=optimized_result,
daily_water_needs=daily_water_needs,
+4 -4
View File
@@ -20,7 +20,7 @@ def rag_ingest_task(recreate: bool = True):
@app.task(bind=True)
def irrigation_recommendation_task(
self,
sensor_uuid: str,
farm_uuid: str,
plant_name: str | None = None,
growth_stage: str | None = None,
irrigation_method_name: str | None = None,
@@ -38,7 +38,7 @@ def irrigation_recommendation_task(
meta={"message": "در حال پردازش توصیه آبیاری..."},
)
result = get_irrigation_recommendation(
sensor_uuid=sensor_uuid,
farm_uuid=farm_uuid,
plant_name=plant_name,
growth_stage=growth_stage,
irrigation_method_name=irrigation_method_name,
@@ -51,7 +51,7 @@ def irrigation_recommendation_task(
@app.task(bind=True)
def fertilization_recommendation_task(
self,
sensor_uuid: str,
farm_uuid: str,
plant_name: str | None = None,
growth_stage: str | None = None,
query: str | None = None,
@@ -68,7 +68,7 @@ def fertilization_recommendation_task(
meta={"message": "در حال پردازش توصیه کودهی..."},
)
result = get_fertilization_recommendation(
sensor_uuid=sensor_uuid,
farm_uuid=farm_uuid,
plant_name=plant_name,
growth_stage=growth_stage,
query=query,
+6 -5
View File
@@ -1,9 +1,10 @@
from unittest.mock import patch
from django.test import TestCase
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
@override_settings(ROOT_URLCONF="config.test_urls")
class RagRecommendationApiTests(TestCase):
def setUp(self):
self.client = APIClient()
@@ -21,7 +22,7 @@ class RagRecommendationApiTests(TestCase):
response = self.client.post(
"/api/rag/recommend/irrigation/",
data={
"sensor_uuid": "sensor-123",
"farm_uuid": "sensor-123",
"plant_name": "گوجه‌فرنگی",
"growth_stage": "میوه‌دهی",
"irrigation_method_name": "قطره‌ای",
@@ -32,7 +33,7 @@ class RagRecommendationApiTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["plan"]["frequencyPerWeek"], 3)
mock_get_irrigation_recommendation.assert_called_once_with(
sensor_uuid="sensor-123",
farm_uuid="sensor-123",
plant_name="گوجه‌فرنگی",
growth_stage="میوه‌دهی",
irrigation_method_name="قطره‌ای",
@@ -52,7 +53,7 @@ class RagRecommendationApiTests(TestCase):
response = self.client.post(
"/api/rag/recommend/fertilization/",
data={
"sensor_uuid": "sensor-456",
"farm_uuid": "sensor-456",
"plant_name": "گندم",
"growth_stage": "رویشی",
},
@@ -62,7 +63,7 @@ class RagRecommendationApiTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["plan"]["npkRatio"], "20-20-20")
mock_get_fertilization_recommendation.assert_called_once_with(
sensor_uuid="sensor-456",
farm_uuid="sensor-456",
plant_name="گندم",
growth_stage="رویشی",
query=None,
+4 -4
View File
@@ -130,7 +130,7 @@ class RecommendationServiceDefaultsTests(TestCase):
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_irrigation_recommendation(
sensor_uuid=str(self.farm_uuid),
farm_uuid=str(self.farm_uuid),
growth_stage="میوه‌دهی",
)
@@ -176,7 +176,7 @@ class RecommendationServiceDefaultsTests(TestCase):
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_irrigation_recommendation(
sensor_uuid=str(self.farm_uuid),
farm_uuid=str(self.farm_uuid),
growth_stage="میوه‌دهی",
irrigation_method_name="بارانی",
)
@@ -205,7 +205,7 @@ class RecommendationServiceDefaultsTests(TestCase):
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_fertilization_recommendation(
sensor_uuid=str(self.farm_uuid),
farm_uuid=str(self.farm_uuid),
growth_stage="رویشی",
)
@@ -232,7 +232,7 @@ class RecommendationServiceDefaultsTests(TestCase):
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_fertilization_recommendation(
sensor_uuid=str(self.farm_uuid),
farm_uuid=str(self.farm_uuid),
growth_stage="رویشی",
)
+2 -2
View File
@@ -8,6 +8,6 @@ from .views import (
urlpatterns = [
path("chat/", ChatView.as_view()),
# path("recommend/irrigation/", IrrigationRecommendationView.as_view(), name="recommend-irrigation"),
# path("recommend/fertilization/", FertilizationRecommendationView.as_view(), name="recommend-fertilization"),
path("recommend/irrigation/", IrrigationRecommendationView.as_view(), name="recommend-irrigation"),
path("recommend/fertilization/", FertilizationRecommendationView.as_view(), name="recommend-fertilization"),
]
+18 -16
View File
@@ -148,7 +148,7 @@ class ChatView(APIView):
class IrrigationRecommendationView(APIView):
"""
توصیه آبیاری به صورت مستقیم.
POST با sensor_uuid، plant_name، growth_stage، irrigation_method_name.
POST با farm_uuid، plant_name، growth_stage، irrigation_method_name.
نتیجه همان لحظه برگشت داده میشود.
"""
@@ -162,7 +162,8 @@ class IrrigationRecommendationView(APIView):
request=inline_serializer(
name="IrrigationRecommendationRequest",
fields={
"sensor_uuid": drf_serializers.CharField(help_text="شناسه یکتای سنسور (اجباری)"),
"farm_uuid": drf_serializers.CharField(help_text="شناسه یکتای مزرعه (اجباری)"),
"sensor_uuid": drf_serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid"),
"plant_name": drf_serializers.CharField(required=False, help_text="نام گیاه"),
"growth_stage": drf_serializers.CharField(required=False, help_text="مرحله رشد گیاه"),
"irrigation_method_name": drf_serializers.CharField(required=False, help_text="نام روش آبیاری"),
@@ -187,7 +188,7 @@ class IrrigationRecommendationView(APIView):
OpenApiExample(
"نمونه درخواست",
value={
"sensor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": "گوجه‌فرنگی",
"growth_stage": "میوه‌دهی",
"irrigation_method_name": "آبیاری قطره‌ای",
@@ -199,23 +200,23 @@ class IrrigationRecommendationView(APIView):
def post(self, request: Request):
from rag.services.irrigation import get_irrigation_recommendation
sensor_uuid = request.data.get("sensor_uuid")
if not sensor_uuid:
farm_uuid = request.data.get("farm_uuid") or request.data.get("sensor_uuid")
if not farm_uuid:
return Response(
{"code": 400, "msg": "پارامتر sensor_uuid الزامی است.", "data": None},
{"code": 400, "msg": "پارامتر farm_uuid الزامی است.", "data": None},
status=status.HTTP_400_BAD_REQUEST,
)
try:
result = get_irrigation_recommendation(
sensor_uuid=str(sensor_uuid),
farm_uuid=str(farm_uuid),
plant_name=request.data.get("plant_name"),
growth_stage=request.data.get("growth_stage"),
irrigation_method_name=request.data.get("irrigation_method_name"),
query=request.data.get("query"),
)
except Exception:
logger.exception("Direct irrigation recommendation failed for sensor %s", sensor_uuid)
logger.exception("Direct irrigation recommendation failed for farm %s", farm_uuid)
return Response(
{"code": 500, "msg": "خطا در تولید توصیه آبیاری.", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -230,7 +231,7 @@ class IrrigationRecommendationView(APIView):
class FertilizationRecommendationView(APIView):
"""
توصیه کودهی به صورت مستقیم.
POST با sensor_uuid، plant_name، growth_stage.
POST با farm_uuid، plant_name، growth_stage.
نتیجه همان لحظه برگشت داده میشود.
"""
@@ -244,7 +245,8 @@ class FertilizationRecommendationView(APIView):
request=inline_serializer(
name="FertilizationRecommendationRequest",
fields={
"sensor_uuid": drf_serializers.CharField(help_text="شناسه یکتای سنسور (اجباری)"),
"farm_uuid": drf_serializers.CharField(help_text="شناسه یکتای مزرعه (اجباری)"),
"sensor_uuid": drf_serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid"),
"plant_name": drf_serializers.CharField(required=False, help_text="نام گیاه"),
"growth_stage": drf_serializers.CharField(required=False, help_text="مرحله رشد گیاه"),
"query": drf_serializers.CharField(required=False, help_text="سوال اختیاری"),
@@ -268,7 +270,7 @@ class FertilizationRecommendationView(APIView):
OpenApiExample(
"نمونه درخواست",
value={
"sensor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": "گوجه‌فرنگی",
"growth_stage": "رویشی",
},
@@ -279,22 +281,22 @@ class FertilizationRecommendationView(APIView):
def post(self, request: Request):
from rag.services.fertilization import get_fertilization_recommendation
sensor_uuid = request.data.get("sensor_uuid")
if not sensor_uuid:
farm_uuid = request.data.get("farm_uuid") or request.data.get("sensor_uuid")
if not farm_uuid:
return Response(
{"code": 400, "msg": "پارامتر sensor_uuid الزامی است.", "data": None},
{"code": 400, "msg": "پارامتر farm_uuid الزامی است.", "data": None},
status=status.HTTP_400_BAD_REQUEST,
)
try:
result = get_fertilization_recommendation(
sensor_uuid=str(sensor_uuid),
farm_uuid=str(farm_uuid),
plant_name=request.data.get("plant_name"),
growth_stage=request.data.get("growth_stage"),
query=request.data.get("query"),
)
except Exception:
logger.exception("Direct fertilization recommendation failed for sensor %s", sensor_uuid)
logger.exception("Direct fertilization recommendation failed for farm %s", farm_uuid)
return Response(
{"code": 500, "msg": "خطا در تولید توصیه کودهی.", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
-1
View File
@@ -31,7 +31,6 @@ numpy>=1.23,<1.27
pcse
# === Vector Databases ===
chromadb>=0.4.24,<0.5
qdrant-client>=1.7,<1.9
# === Utils ===