diff --git a/Dockerfile b/Dockerfile index 3fc7fc7..c267694 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 . . diff --git a/config/test_urls.py b/config/test_urls.py index 4f07b1e..c884f06 100644 --- a/config/test_urls.py +++ b/config/test_urls.py @@ -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")), ] diff --git a/constraints.txt b/constraints.txt new file mode 100644 index 0000000..a2f5bdb --- /dev/null +++ b/constraints.txt @@ -0,0 +1,2 @@ +# Keep build-isolated dependency resolution compatible with Python 3.10. +numpy>=1.23,<1.27 diff --git a/crop_simulation/SERVICES_INTEGRATION.md b/crop_simulation/SERVICES_INTEGRATION.md index f49ef4b..16f0317 100644 --- a/crop_simulation/SERVICES_INTEGRATION.md +++ b/crop_simulation/SERVICES_INTEGRATION.md @@ -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": [...], diff --git a/crop_simulation/growth_simulation.py b/crop_simulation/growth_simulation.py index 0fddbeb..8fde124 100644 --- a/crop_simulation/growth_simulation.py +++ b/crop_simulation/growth_simulation.py @@ -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, diff --git a/crop_simulation/migrations/0002_alter_simulationscenario_model_name.py b/crop_simulation/migrations/0002_alter_simulationscenario_model_name.py new file mode 100644 index 0000000..1c1034e --- /dev/null +++ b/crop_simulation/migrations/0002_alter_simulationscenario_model_name.py @@ -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), + ), + ] diff --git a/crop_simulation/models.py b/crop_simulation/models.py index 150c95a..c556a71 100644 --- a/crop_simulation/models.py +++ b/crop_simulation/models.py @@ -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, diff --git a/crop_simulation/recommendation_optimizer.py b/crop_simulation/recommendation_optimizer.py index 3bed079..5ac64fa 100644 --- a/crop_simulation/recommendation_optimizer.py +++ b/crop_simulation/recommendation_optimizer.py @@ -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']}", ) diff --git a/crop_simulation/services.py b/crop_simulation/services.py index 336a32d..c5483b4 100644 --- a/crop_simulation/services.py +++ b/crop_simulation/services.py @@ -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 diff --git a/crop_simulation/tests.py b/crop_simulation/tests.py index 5d13879..4c0f4df 100644 --- a/crop_simulation/tests.py +++ b/crop_simulation/tests.py @@ -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, diff --git a/fertilization/apps.py b/fertilization/apps.py index 34ee649..2fc19c7 100644 --- a/fertilization/apps.py +++ b/fertilization/apps.py @@ -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": { diff --git a/fertilization/serializers.py b/fertilization/serializers.py index 554145a..0950613 100644 --- a/fertilization/serializers.py +++ b/fertilization/serializers.py @@ -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): """سریالایزر خروجی برای پلن توصیه کودهی.""" diff --git a/fertilization/views.py b/fertilization/views.py index b538281..e8d8673 100644 --- a/fertilization/views.py +++ b/fertilization/views.py @@ -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, diff --git a/irrigation/apps.py b/irrigation/apps.py index 9578144..4e6aa74 100644 --- a/irrigation/apps.py +++ b/irrigation/apps.py @@ -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, diff --git a/irrigation/serializers.py b/irrigation/serializers.py index 60fef6d..ff806d2 100644 --- a/irrigation/serializers.py +++ b/irrigation/serializers.py @@ -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): """سریالایزر خروجی برای پلن توصیه آبیاری.""" diff --git a/irrigation/views.py b/irrigation/views.py index 822025d..49a757d 100644 --- a/irrigation/views.py +++ b/irrigation/views.py @@ -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, diff --git a/rag/services/fertilization.py b/rag/services/fertilization.py index 081f109..37dead5 100644 --- a/rag/services/fertilization.py +++ b/rag/services/fertilization.py @@ -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 diff --git a/rag/services/irrigation.py b/rag/services/irrigation.py index c7c6056..9069cc1 100644 --- a/rag/services/irrigation.py +++ b/rag/services/irrigation.py @@ -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, diff --git a/rag/tasks.py b/rag/tasks.py index 3dbc958..5b05e4f 100644 --- a/rag/tasks.py +++ b/rag/tasks.py @@ -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, diff --git a/rag/tests/test_recommendation_api.py b/rag/tests/test_recommendation_api.py index 60ad9a6..9c7d7d6 100644 --- a/rag/tests/test_recommendation_api.py +++ b/rag/tests/test_recommendation_api.py @@ -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, diff --git a/rag/tests/test_recommendation_services.py b/rag/tests/test_recommendation_services.py index e4f14b3..71e0136 100644 --- a/rag/tests/test_recommendation_services.py +++ b/rag/tests/test_recommendation_services.py @@ -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="رویشی", ) diff --git a/rag/urls.py b/rag/urls.py index 63d75d3..3b64bcb 100644 --- a/rag/urls.py +++ b/rag/urls.py @@ -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"), ] diff --git a/rag/views.py b/rag/views.py index 4780adb..6a1137b 100644 --- a/rag/views.py +++ b/rag/views.py @@ -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, diff --git a/requirements.txt b/requirements.txt index ff1a0e8..1022d47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 ===