UPDATE
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 66 KiB |
@@ -6,7 +6,7 @@ from statistics import mean
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from location_data.satellite_snapshot import build_location_satellite_snapshot
|
from farm_data.services import get_ai_snapshot_metric
|
||||||
|
|
||||||
from crop_simulation.services import CropSimulationService
|
from crop_simulation.services import CropSimulationService
|
||||||
|
|
||||||
@@ -47,21 +47,10 @@ def _first_not_none(*values: Any) -> Any:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _sensor_metric(sensor: Any, metric: str) -> float | None:
|
def _aggregated_metric(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
|
||||||
if sensor is None:
|
value = get_ai_snapshot_metric(ai_snapshot, metric)
|
||||||
return None
|
|
||||||
if hasattr(sensor, metric):
|
|
||||||
value = getattr(sensor, metric)
|
|
||||||
return _safe_float(value, default=0.0) if value is not None else None
|
return _safe_float(value, default=0.0) if value is not None else None
|
||||||
|
|
||||||
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) is not None:
|
|
||||||
return _safe_float(block.get(metric), default=0.0)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_temperature_range(plant: Any) -> tuple[float, float]:
|
def _parse_temperature_range(plant: Any) -> tuple[float, float]:
|
||||||
raw = (getattr(plant, "temperature", "") or "").replace("تا", "-")
|
raw = (getattr(plant, "temperature", "") or "").replace("تا", "-")
|
||||||
@@ -140,15 +129,9 @@ def _build_weather_records(forecasts: list[Any], *, latitude: float, longitude:
|
|||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
def _build_soil_parameters(sensor: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
def _build_soil_parameters(sensor: Any, ai_snapshot: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
moisture_pct = _sensor_metric(sensor, "soil_moisture")
|
moisture_pct = _aggregated_metric(ai_snapshot, "soil_moisture")
|
||||||
center_location = getattr(sensor, "center_location", None)
|
ndwi = _safe_float(_aggregated_metric(ai_snapshot, "ndwi"), 0.34)
|
||||||
satellite_metrics = (
|
|
||||||
build_location_satellite_snapshot(center_location).get("resolved_metrics") or {}
|
|
||||||
if center_location is not None
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.34)
|
|
||||||
wv0033 = ndwi if ndwi > 0 else 0.34
|
wv0033 = ndwi if ndwi > 0 else 0.34
|
||||||
wv1500 = max(round(wv0033 * 0.45, 3), 0.14)
|
wv1500 = max(round(wv0033 * 0.45, 3), 0.14)
|
||||||
|
|
||||||
@@ -288,6 +271,7 @@ class SimulationRecommendationOptimizer:
|
|||||||
daily_water_needs: list[dict[str, Any]],
|
daily_water_needs: list[dict[str, Any]],
|
||||||
growth_stage: str | None,
|
growth_stage: str | None,
|
||||||
irrigation_method: Any | None,
|
irrigation_method: Any | None,
|
||||||
|
ai_snapshot: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
if sensor is None or plant is None or not forecasts:
|
if sensor is None or plant is None or not forecasts:
|
||||||
return None
|
return None
|
||||||
@@ -301,6 +285,7 @@ class SimulationRecommendationOptimizer:
|
|||||||
daily_water_needs=daily_water_needs,
|
daily_water_needs=daily_water_needs,
|
||||||
growth_stage=growth_stage,
|
growth_stage=growth_stage,
|
||||||
crop_blueprint=crop_blueprint,
|
crop_blueprint=crop_blueprint,
|
||||||
|
ai_snapshot=ai_snapshot,
|
||||||
)
|
)
|
||||||
if pcse_result is not None:
|
if pcse_result is not None:
|
||||||
return pcse_result
|
return pcse_result
|
||||||
@@ -312,6 +297,7 @@ class SimulationRecommendationOptimizer:
|
|||||||
daily_water_needs=daily_water_needs,
|
daily_water_needs=daily_water_needs,
|
||||||
growth_stage=growth_stage,
|
growth_stage=growth_stage,
|
||||||
irrigation_method=irrigation_method,
|
irrigation_method=irrigation_method,
|
||||||
|
ai_snapshot=ai_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
def optimize_fertilization(
|
def optimize_fertilization(
|
||||||
@@ -321,6 +307,7 @@ class SimulationRecommendationOptimizer:
|
|||||||
plant: Any,
|
plant: Any,
|
||||||
forecasts: list[Any],
|
forecasts: list[Any],
|
||||||
growth_stage: str | None,
|
growth_stage: str | None,
|
||||||
|
ai_snapshot: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
if sensor is None or plant is None:
|
if sensor is None or plant is None:
|
||||||
return None
|
return None
|
||||||
@@ -333,6 +320,7 @@ class SimulationRecommendationOptimizer:
|
|||||||
forecasts=forecasts,
|
forecasts=forecasts,
|
||||||
growth_stage=growth_stage,
|
growth_stage=growth_stage,
|
||||||
crop_blueprint=crop_blueprint,
|
crop_blueprint=crop_blueprint,
|
||||||
|
ai_snapshot=ai_snapshot,
|
||||||
)
|
)
|
||||||
if pcse_result is not None:
|
if pcse_result is not None:
|
||||||
return pcse_result
|
return pcse_result
|
||||||
@@ -342,6 +330,7 @@ class SimulationRecommendationOptimizer:
|
|||||||
plant=plant,
|
plant=plant,
|
||||||
forecasts=forecasts,
|
forecasts=forecasts,
|
||||||
growth_stage=growth_stage,
|
growth_stage=growth_stage,
|
||||||
|
ai_snapshot=ai_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _optimize_irrigation_with_pcse(
|
def _optimize_irrigation_with_pcse(
|
||||||
@@ -353,10 +342,11 @@ class SimulationRecommendationOptimizer:
|
|||||||
daily_water_needs: list[dict[str, Any]],
|
daily_water_needs: list[dict[str, Any]],
|
||||||
growth_stage: str | None,
|
growth_stage: str | None,
|
||||||
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
|
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
|
||||||
|
ai_snapshot: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
defaults = apps.get_app_config("irrigation").get_optimizer_defaults()
|
defaults = apps.get_app_config("irrigation").get_optimizer_defaults()
|
||||||
crop_parameters, agromanagement = crop_blueprint
|
crop_parameters, agromanagement = crop_blueprint
|
||||||
soil, site = _build_soil_parameters(sensor)
|
soil, site = _build_soil_parameters(sensor, ai_snapshot=ai_snapshot)
|
||||||
weather = _build_weather_records(
|
weather = _build_weather_records(
|
||||||
forecasts,
|
forecasts,
|
||||||
latitude=_safe_float(sensor.center_location.latitude),
|
latitude=_safe_float(sensor.center_location.latitude),
|
||||||
@@ -572,10 +562,11 @@ class SimulationRecommendationOptimizer:
|
|||||||
forecasts: list[Any],
|
forecasts: list[Any],
|
||||||
growth_stage: str | None,
|
growth_stage: str | None,
|
||||||
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
|
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
|
||||||
|
ai_snapshot: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
|
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
|
||||||
crop_parameters, agromanagement = crop_blueprint
|
crop_parameters, agromanagement = crop_blueprint
|
||||||
soil, site = _build_soil_parameters(sensor)
|
soil, site = _build_soil_parameters(sensor, ai_snapshot=ai_snapshot)
|
||||||
weather = _build_weather_records(
|
weather = _build_weather_records(
|
||||||
forecasts,
|
forecasts,
|
||||||
latitude=_safe_float(sensor.center_location.latitude),
|
latitude=_safe_float(sensor.center_location.latitude),
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from datetime import date, datetime, timedelta
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from location_data.satellite_snapshot import build_location_satellite_snapshot
|
|
||||||
|
from farm_data.services import build_ai_farm_snapshot, get_ai_snapshot_weather
|
||||||
|
|
||||||
from .models import SimulationRun, SimulationScenario
|
from .models import SimulationRun, SimulationScenario
|
||||||
|
|
||||||
@@ -303,23 +304,22 @@ def _clamp(value: float, lower: float, upper: float) -> float:
|
|||||||
return max(lower, min(value, upper))
|
return max(lower, min(value, upper))
|
||||||
|
|
||||||
|
|
||||||
def _sensor_metric(sensor: Any, metric_name: str) -> float | None:
|
def _snapshot_metric(snapshot: dict[str, Any] | None, metric_name: str) -> float | None:
|
||||||
if sensor is None:
|
if not isinstance(snapshot, dict):
|
||||||
return None
|
return None
|
||||||
|
farm_metrics = snapshot.get("farm_metrics") or {}
|
||||||
if hasattr(sensor, metric_name):
|
resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {}
|
||||||
value = getattr(sensor, metric_name)
|
if not isinstance(resolved_metrics, dict):
|
||||||
if value is not None:
|
|
||||||
return _safe_float(value)
|
|
||||||
|
|
||||||
payload = getattr(sensor, "sensor_payload", None) or {}
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return None
|
return None
|
||||||
|
return _safe_float(resolved_metrics.get(metric_name))
|
||||||
|
|
||||||
for block in payload.values():
|
|
||||||
if isinstance(block, dict) and block.get(metric_name) is not None:
|
|
||||||
return _safe_float(block.get(metric_name))
|
def _snapshot_source_metadata(snapshot: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
return None
|
if not isinstance(snapshot, dict):
|
||||||
|
return {}
|
||||||
|
source_metadata = snapshot.get("source_metadata") or {}
|
||||||
|
return source_metadata if isinstance(source_metadata, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def _extract_plant_simulation_profile(plant: Any | None) -> dict[str, Any] | None:
|
def _extract_plant_simulation_profile(plant: Any | None) -> dict[str, Any] | None:
|
||||||
@@ -458,6 +458,9 @@ def build_simulation_payload_from_farm(
|
|||||||
raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.")
|
raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.")
|
||||||
|
|
||||||
plant = get_runtime_plant_for_farm(farm, plant_name=plant_name)
|
plant = get_runtime_plant_for_farm(farm, plant_name=plant_name)
|
||||||
|
ai_snapshot = build_ai_farm_snapshot(str(farm_uuid))
|
||||||
|
if ai_snapshot is None:
|
||||||
|
raise CropSimulationError(f"Canonical AI snapshot for farm uuid={farm_uuid} is missing.")
|
||||||
|
|
||||||
if weather is not None:
|
if weather is not None:
|
||||||
resolved_weather = _normalize_weather_records(weather)
|
resolved_weather = _normalize_weather_records(weather)
|
||||||
@@ -476,8 +479,7 @@ def build_simulation_payload_from_farm(
|
|||||||
longitude=float(farm.center_location.longitude),
|
longitude=float(farm.center_location.longitude),
|
||||||
)
|
)
|
||||||
|
|
||||||
satellite_metrics = build_location_satellite_snapshot(farm.center_location).get("resolved_metrics") or {}
|
ndwi = _snapshot_metric(ai_snapshot, "ndwi")
|
||||||
ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.28)
|
|
||||||
smfcf = _clamp(ndwi if ndwi is not None else 0.34, 0.2, 0.55)
|
smfcf = _clamp(ndwi if ndwi is not None else 0.34, 0.2, 0.55)
|
||||||
smw = _clamp(smfcf * 0.45, 0.05, max(smfcf - 0.02, 0.06))
|
smw = _clamp(smfcf * 0.45, 0.05, max(smfcf - 0.02, 0.06))
|
||||||
sm0 = _clamp(
|
sm0 = _clamp(
|
||||||
@@ -485,17 +487,17 @@ def build_simulation_payload_from_farm(
|
|||||||
max(smfcf + 0.02, smw + 0.05),
|
max(smfcf + 0.02, smw + 0.05),
|
||||||
0.8,
|
0.8,
|
||||||
)
|
)
|
||||||
soil_moisture = _sensor_metric(farm, "soil_moisture")
|
soil_moisture = _snapshot_metric(ai_snapshot, "soil_moisture")
|
||||||
wav = (
|
wav = (
|
||||||
round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3)
|
round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3)
|
||||||
if soil_moisture is not None
|
if soil_moisture is not None
|
||||||
else DEFAULT_WAV
|
else DEFAULT_WAV
|
||||||
)
|
)
|
||||||
nitrogen = _pick_first_not_none(_sensor_metric(farm, "nitrogen"), satellite_metrics.get("soil_vv_db"))
|
nitrogen = _pick_first_not_none(_snapshot_metric(ai_snapshot, "nitrogen"), _snapshot_metric(ai_snapshot, "soil_vv_db"))
|
||||||
phosphorus = _sensor_metric(farm, "phosphorus")
|
phosphorus = _snapshot_metric(ai_snapshot, "phosphorus")
|
||||||
potassium = _sensor_metric(farm, "potassium")
|
potassium = _snapshot_metric(ai_snapshot, "potassium")
|
||||||
soil_ph = _pick_first_not_none(_sensor_metric(farm, "soil_ph"), None)
|
soil_ph = _pick_first_not_none(_snapshot_metric(ai_snapshot, "soil_ph"), None)
|
||||||
ec = _sensor_metric(farm, "electrical_conductivity")
|
ec = _snapshot_metric(ai_snapshot, "electrical_conductivity")
|
||||||
|
|
||||||
resolved_soil = {
|
resolved_soil = {
|
||||||
"SMFCF": round(smfcf, 3),
|
"SMFCF": round(smfcf, 3),
|
||||||
@@ -569,6 +571,13 @@ def build_simulation_payload_from_farm(
|
|||||||
"site_parameters": resolved_site,
|
"site_parameters": resolved_site,
|
||||||
"crop_parameters": resolved_crop,
|
"crop_parameters": resolved_crop,
|
||||||
"agromanagement": resolved_agromanagement,
|
"agromanagement": resolved_agromanagement,
|
||||||
|
"source_metadata": {
|
||||||
|
"farm_metrics": _snapshot_source_metadata(ai_snapshot).get("farm_metrics", {}),
|
||||||
|
"weather": {
|
||||||
|
"source": "center_location_forecast",
|
||||||
|
"policy": "center_location_latest_forecast",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -914,6 +923,7 @@ class CropSimulationService:
|
|||||||
"fertilization_recommendation": fertilization_recommendation or {},
|
"fertilization_recommendation": fertilization_recommendation or {},
|
||||||
"farm_uuid": farm_uuid,
|
"farm_uuid": farm_uuid,
|
||||||
"plant_name": plant_name,
|
"plant_name": plant_name,
|
||||||
|
"source_metadata": resolved.get("source_metadata") or {},
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -937,6 +947,7 @@ class CropSimulationService:
|
|||||||
"crop_parameters": resolved["crop_parameters"],
|
"crop_parameters": resolved["crop_parameters"],
|
||||||
"site_parameters": resolved["site_parameters"],
|
"site_parameters": resolved["site_parameters"],
|
||||||
"agromanagement": merged_agromanagement,
|
"agromanagement": merged_agromanagement,
|
||||||
|
"source_metadata": resolved.get("source_metadata") or {},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1205,6 +1216,7 @@ class CropSimulationService:
|
|||||||
"crop_parameters": resolved["crop_parameters"],
|
"crop_parameters": resolved["crop_parameters"],
|
||||||
"site_parameters": resolved["site_parameters"],
|
"site_parameters": resolved["site_parameters"],
|
||||||
"agromanagement": merged_agromanagement,
|
"agromanagement": merged_agromanagement,
|
||||||
|
"source_metadata": resolved.get("source_metadata") or {},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self._execute_scenario(scenario=scenario, run_specs=run_specs)
|
return self._execute_scenario(scenario=scenario, run_specs=run_specs)
|
||||||
@@ -1258,7 +1270,7 @@ class CropSimulationService:
|
|||||||
site_parameters=spec["site_parameters"],
|
site_parameters=spec["site_parameters"],
|
||||||
)
|
)
|
||||||
run.status = SimulationScenario.Status.SUCCESS
|
run.status = SimulationScenario.Status.SUCCESS
|
||||||
run.result_payload = result
|
run.result_payload = {**result, "source_metadata": spec.get("source_metadata") or {}}
|
||||||
run.save(update_fields=["status", "result_payload", "updated_at"])
|
run.save(update_fields=["status", "result_payload", "updated_at"])
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ from django.test import TestCase
|
|||||||
from rest_framework.test import APIRequestFactory
|
from rest_framework.test import APIRequestFactory
|
||||||
|
|
||||||
from .models import SimulationRun, SimulationScenario
|
from .models import SimulationRun, SimulationScenario
|
||||||
|
from farm_data.models import PlantCatalogSnapshot, SensorData
|
||||||
|
from irrigation.models import IrrigationMethod
|
||||||
|
from location_data.models import SoilLocation
|
||||||
|
from weather.models import WeatherForecast
|
||||||
from .services import CropSimulationService, CropSimulationError, PcseSimulationManager
|
from .services import CropSimulationService, CropSimulationError, PcseSimulationManager
|
||||||
from .views import PlantGrowthSimulationView
|
from .views import PlantGrowthSimulationView
|
||||||
|
|
||||||
@@ -366,3 +370,97 @@ class CropSimulationPcseIntegrationTests(TestCase):
|
|||||||
self.assertEqual(result["result"]["engine"], "pcse")
|
self.assertEqual(result["result"]["engine"], "pcse")
|
||||||
self.assertIsNotNone(result["result"]["metrics"]["yield_estimate"])
|
self.assertIsNotNone(result["result"]["metrics"]["yield_estimate"])
|
||||||
self.assertIsNotNone(result["result"]["metrics"]["biomass"])
|
self.assertIsNotNone(result["result"]["metrics"]["biomass"])
|
||||||
|
|
||||||
|
|
||||||
|
class CropSimulationCanonicalSnapshotTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.location = SoilLocation.objects.create(latitude="35.700000", longitude="51.400000")
|
||||||
|
self.weather = WeatherForecast.objects.create(
|
||||||
|
location=self.location,
|
||||||
|
forecast_date=date(2026, 4, 10),
|
||||||
|
temperature_min=12.0,
|
||||||
|
temperature_max=24.0,
|
||||||
|
temperature_mean=18.0,
|
||||||
|
humidity_mean=55.0,
|
||||||
|
precipitation=1.0,
|
||||||
|
et0=3.5,
|
||||||
|
)
|
||||||
|
self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=401, name="wheat")
|
||||||
|
self.irrigation_method = IrrigationMethod.objects.create(name="drip")
|
||||||
|
self.farm = SensorData.objects.create(
|
||||||
|
farm_uuid="550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
center_location=self.location,
|
||||||
|
weather_forecast=self.weather,
|
||||||
|
irrigation_method=self.irrigation_method,
|
||||||
|
)
|
||||||
|
self.farm.plants.add(self.plant)
|
||||||
|
|
||||||
|
@patch("crop_simulation.services.build_ai_farm_snapshot")
|
||||||
|
def test_build_simulation_payload_from_farm_uses_aggregated_metrics(self, mock_snapshot):
|
||||||
|
from crop_simulation.services import build_simulation_payload_from_farm
|
||||||
|
|
||||||
|
mock_snapshot.return_value = {
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"farm_metrics": {
|
||||||
|
"resolved_metrics": {
|
||||||
|
"soil_moisture": 36.0,
|
||||||
|
"ndwi": 0.31,
|
||||||
|
"nitrogen": 21.0,
|
||||||
|
"phosphorus": 11.0,
|
||||||
|
"potassium": 17.0,
|
||||||
|
"soil_ph": 6.8,
|
||||||
|
"electrical_conductivity": 1.4,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"source_metadata": {
|
||||||
|
"farm_metrics": {
|
||||||
|
"canonical_source": "farmer_block_aggregated_snapshot",
|
||||||
|
"aggregation_strategy": "farmer_block_mean",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = build_simulation_payload_from_farm(farm_uuid=str(self.farm.farm_uuid), plant_name="wheat")
|
||||||
|
|
||||||
|
self.assertEqual(payload["soil"]["soil_moisture"], 36.0)
|
||||||
|
self.assertEqual(payload["site_parameters"]["NAVAILI"], 21.0)
|
||||||
|
self.assertEqual(payload["soil"]["phosphorus"], 11.0)
|
||||||
|
self.assertEqual(payload["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||||
|
|
||||||
|
@patch("crop_simulation.services.build_ai_farm_snapshot")
|
||||||
|
def test_build_simulation_payload_from_farm_handles_missing_block_metrics(self, mock_snapshot):
|
||||||
|
from crop_simulation.services import build_simulation_payload_from_farm
|
||||||
|
|
||||||
|
mock_snapshot.return_value = {
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"farm_metrics": {"resolved_metrics": {}},
|
||||||
|
"source_metadata": {"farm_metrics": {"status": "missing"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = build_simulation_payload_from_farm(farm_uuid=str(self.farm.farm_uuid), plant_name="wheat")
|
||||||
|
|
||||||
|
self.assertEqual(payload["site_parameters"]["WAV"], 40.0)
|
||||||
|
self.assertEqual(payload["source_metadata"]["farm_metrics"]["status"], "missing")
|
||||||
|
|
||||||
|
@patch("crop_simulation.services.build_ai_farm_snapshot")
|
||||||
|
def test_run_single_simulation_stores_weather_provenance(self, mock_snapshot):
|
||||||
|
mock_snapshot.return_value = {
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"farm_metrics": {"resolved_metrics": {"soil_moisture": 35.0, "ndwi": 0.3}},
|
||||||
|
"source_metadata": {
|
||||||
|
"farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"},
|
||||||
|
"weather": {"policy": "center_location_latest_forecast"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
service = CropSimulationService()
|
||||||
|
|
||||||
|
with patch.object(service.manager, "run_simulation", return_value={"engine": "pcse", "metrics": {}, "daily_output": [], "summary_output": [], "terminal_output": []}):
|
||||||
|
result = service.run_single_simulation(
|
||||||
|
farm_uuid=str(self.farm.farm_uuid),
|
||||||
|
plant_name="wheat",
|
||||||
|
agromanagement=build_agromanagement(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["result"]["source_metadata"]["weather"]["policy"], "center_location_latest_forecast")
|
||||||
|
run = SimulationRun.objects.get()
|
||||||
|
self.assertEqual(run.result_payload["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from django.apps import apps
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from farm_data.models import SensorData
|
from farm_data.models import SensorData
|
||||||
from farm_data.services import get_farm_details
|
from farm_data.services import build_ai_farm_snapshot
|
||||||
from location_data.models import NdviObservation, SoilLocation
|
from location_data.models import NdviObservation, SoilLocation
|
||||||
|
|
||||||
from rag.failure_contract import RAGServiceError
|
from rag.failure_contract import RAGServiceError
|
||||||
@@ -720,13 +720,13 @@ class YieldHarvestSummaryService:
|
|||||||
"recent_sensor_averages": {},
|
"recent_sensor_averages": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
farm_details = get_farm_details(str(farm_uuid)) or {}
|
farm_details = build_ai_farm_snapshot(str(farm_uuid)) or {}
|
||||||
center_location = farm.center_location
|
center_location = farm.center_location
|
||||||
soil_details = (farm_details.get("soil") or {}).get("resolved_metrics") or {}
|
soil_details = (farm_details.get("farm_metrics") or {}).get("resolved_metrics") or {}
|
||||||
weather_details = farm_details.get("weather") or {}
|
weather_details = ((farm_details.get("weather") or {}).get("forecast") or {})
|
||||||
recent_sensor_averages = {
|
recent_sensor_averages = {
|
||||||
"soil_moisture": self._safe_float(soil_details.get("soil_moisture", farm.soil_moisture), None),
|
"soil_moisture": self._safe_float(soil_details.get("soil_moisture"), None),
|
||||||
"soil_temperature": self._safe_float(soil_details.get("soil_temperature", farm.soil_temperature), None),
|
"soil_temperature": self._safe_float(soil_details.get("soil_temperature"), None),
|
||||||
"air_temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None),
|
"air_temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,7 +744,7 @@ class YieldHarvestSummaryService:
|
|||||||
"lat": float(center_location.latitude),
|
"lat": float(center_location.latitude),
|
||||||
"lon": float(center_location.longitude),
|
"lon": float(center_location.longitude),
|
||||||
},
|
},
|
||||||
"farm_boundary": farm_details.get("center_location", {}).get("farm_boundary"),
|
"farm_boundary": getattr(center_location, "farm_boundary", None),
|
||||||
"soil": {
|
"soil": {
|
||||||
"provider": getattr(settings, "SOIL_DATA_PROVIDER", "نامشخص"),
|
"provider": getattr(settings, "SOIL_DATA_PROVIDER", "نامشخص"),
|
||||||
"soil_type": self._infer_soil_type(soil_details),
|
"soil_type": self._infer_soil_type(soil_details),
|
||||||
@@ -760,6 +760,10 @@ class YieldHarvestSummaryService:
|
|||||||
"sensor_data": SensorData.__name__,
|
"sensor_data": SensorData.__name__,
|
||||||
"soil_location": SoilLocation.__name__,
|
"soil_location": SoilLocation.__name__,
|
||||||
},
|
},
|
||||||
|
"source_metadata": {
|
||||||
|
"farm_metrics": (farm_details.get("source_metadata") or {}).get("farm_metrics", {}),
|
||||||
|
"weather": ((farm_details.get("weather") or {}).get("source_metadata") or {}),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _extract_pcse_dvs_stage(self, harvest_prediction_card: dict[str, Any]) -> float:
|
def _extract_pcse_dvs_stage(self, harvest_prediction_card: dict[str, Any]) -> float:
|
||||||
|
|||||||
@@ -0,0 +1,677 @@
|
|||||||
|
# سیاست منبع داده و تجمیع کلاستر برای APIهای AI
|
||||||
|
|
||||||
|
این سند مشخص میکند هر API از کجا داده میگیرد، آیا الان خروجی آن بر اساس میانگین داده سنسور کلاسترها و میانگین داده ماهوارهای کلاسترهای `location_data` هست یا نه، و برای یکپارچهسازی چه policyای باید رعایت شود.
|
||||||
|
|
||||||
|
این سند بر اساس کد فعلی پروژه نوشته شده و مخصوص APIهای زیر است:
|
||||||
|
|
||||||
|
- `POST /api/rag/chat/`
|
||||||
|
- `POST /api/soile/anomaly-detection/`
|
||||||
|
- `POST /api/soile/health-summary/`
|
||||||
|
- `POST /api/soile/moisture-heatmap/`
|
||||||
|
- `POST /api/weather/farm-card/`
|
||||||
|
- `POST /api/weather/water-need-prediction/`
|
||||||
|
- `POST /api/pest-disease/detect/`
|
||||||
|
- `POST /api/pest-disease/risk/`
|
||||||
|
- `POST /api/irrigation/plan-from-text/`
|
||||||
|
- `POST /api/irrigation/recommend/`
|
||||||
|
- `POST /api/farm-data/parameters/`
|
||||||
|
- `POST /api/fertilization/plan-from-text/`
|
||||||
|
- `POST /api/fertilization/recommend/`
|
||||||
|
- `POST /api/crop-simulation/current-farm-chart/`
|
||||||
|
- `POST /api/crop-simulation/growth/`
|
||||||
|
- `GET /api/crop-simulation/growth/{task_id}/status/`
|
||||||
|
- `POST /api/crop-simulation/harvest-prediction/`
|
||||||
|
- `GET /api/crop-simulation/yield-harvest-summary/`
|
||||||
|
- `POST /api/crop-simulation/yield-prediction/`
|
||||||
|
- `POST /api/economy/overview/`
|
||||||
|
- `POST /api/farm-alerts/tracker/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) قانون هدف کسبوکاری
|
||||||
|
|
||||||
|
قانون مطلوبی که باید برای محاسبههای عمومی AI رعایت شود این است:
|
||||||
|
|
||||||
|
1. برای محاسبههای عمومی AI مثل `RAG`، `crop_simulation`، `irrigation`، `fertilization`، `farm_alerts` و سرویسهای تحلیلی خاک، مبنای داده باید `میانگین داده سنسورهای کلاسترها` و `میانگین داده ماهوارهای کلاسترهای location_data` باشد.
|
||||||
|
2. این تجمیع باید در سطح `بلوکهای بزرگ کشاورز` انجام شود؛ یعنی اول هر block اصلی جداگانه محاسبه شود، بعد در صورت نیاز یک خلاصه کل مزرعه از روی blockهای اصلی ساخته شود.
|
||||||
|
3. اگر کشاورز هنوز block تعریف نکرده باشد، حالت پیشفرض دامنه باید این باشد:
|
||||||
|
- `1 بلوک بزرگ` برای کل مزرعه
|
||||||
|
- `1 بلوک کوچک` داخل همان بلوک بزرگ
|
||||||
|
4. هر API که فقط `farm_uuid` میگیرد و رفتار عمومی مزرعه را تحلیل میکند، باید از snapshot تجمیعشدهی مبتنی بر block/sub-block استفاده کند، نه از یک سنسور خام یا یک location خام.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) وضعیت فعلی مدل داده
|
||||||
|
|
||||||
|
### 2.1) لایه مزرعه
|
||||||
|
|
||||||
|
رکورد canonical مزرعه در `farm_data.SensorData` نگهداری میشود:
|
||||||
|
|
||||||
|
- شناسه اصلی: `farm_uuid`
|
||||||
|
- اتصال مکانی: `center_location -> location_data.SoilLocation`
|
||||||
|
- سنسورها: `sensor_payload`
|
||||||
|
- آبوهوا: `weather_forecast`
|
||||||
|
- گیاه: `plant_assignments` و `plant_snapshots`
|
||||||
|
- روش آبیاری: `irrigation_method`
|
||||||
|
|
||||||
|
### 2.2) لایه مکانی
|
||||||
|
|
||||||
|
رکورد canonical مکانی در `location_data.SoilLocation` است:
|
||||||
|
|
||||||
|
- `farm_boundary`
|
||||||
|
- `input_block_count`
|
||||||
|
- `block_layout`
|
||||||
|
- `block_subdivisions`
|
||||||
|
- `remote_sensing_runs`
|
||||||
|
|
||||||
|
### 2.3) لایه کلاستر و سنجش از دور
|
||||||
|
|
||||||
|
در `location_data` عملاً دو سطح تجمیع وجود دارد:
|
||||||
|
|
||||||
|
- `block` = بلوک اصلی که کشاورز تعریف کرده
|
||||||
|
- `sub-block / cluster` = بخشهای کوچکتر داخل هر block
|
||||||
|
|
||||||
|
منطق فعلی تجمیع در `location_data/satellite_snapshot.py` پیاده شده است:
|
||||||
|
|
||||||
|
- `build_block_sensor_summary(...)`
|
||||||
|
- داده سنسورهای منتسب به sub-blockها را جمع میکند
|
||||||
|
- ابتدا برای هر sub-block میانگین میسازد
|
||||||
|
- سپس از روی sub-blockها میانگین block را میسازد
|
||||||
|
- `summarize_block_satellite_metrics(...)`
|
||||||
|
- دادههای grid/cell ماهوارهای را برای cluster blockها خلاصه میکند
|
||||||
|
- سپس میانگین block را میسازد
|
||||||
|
- `build_location_block_satellite_snapshots(...)`
|
||||||
|
- برای هر block اصلی یک snapshot میسازد
|
||||||
|
- `build_farmer_block_aggregated_snapshot(...)`
|
||||||
|
- از روی blockهای اصلی یک خلاصه کل مزرعه میسازد
|
||||||
|
|
||||||
|
پس زیرساخت فنی برای policy موردنظر تا حد زیادی در پروژه وجود دارد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) رفتار پیشفرض blockها
|
||||||
|
|
||||||
|
### رفتار فعلی در کد
|
||||||
|
|
||||||
|
تابع `build_block_layout()` در `location_data/models.py` وقتی blockی داده نشود، این رفتار را دارد:
|
||||||
|
|
||||||
|
- `input_block_count = 1`
|
||||||
|
- فقط `1 بلوک اصلی` با `block-1` ساخته میشود
|
||||||
|
- `sub_blocks = []`
|
||||||
|
|
||||||
|
یعنی الان بخش دوم rule شما کامل اعمال نشده، چون:
|
||||||
|
|
||||||
|
- `1 بلوک بزرگ` وجود دارد
|
||||||
|
- ولی `1 بلوک کوچک داخل آن` بهصورت default ساخته نمیشود
|
||||||
|
|
||||||
|
### رفتار مطلوب پیشنهادی
|
||||||
|
|
||||||
|
برای سازگاری با خواسته شما، policy پیشنهادی این است:
|
||||||
|
|
||||||
|
- اگر هیچ block و هیچ subdivisionی تعریف نشده بود:
|
||||||
|
- `block_layout.blocks = [block-1]`
|
||||||
|
- داخل `block-1.sub_blocks` یک sub-block پیشفرض ساخته شود
|
||||||
|
- این sub-block پیشفرض میتواند چیزی شبیه این داشته باشد:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sub_block_code": "block-1-sub-1",
|
||||||
|
"cluster_label": 0,
|
||||||
|
"source": "default",
|
||||||
|
"boundary": {},
|
||||||
|
"cluster_uuid": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
این policy باعث میشود تمام APIهای downstream همیشه حداقل یک سطح `sub-block` داشته باشند و منطق aggregation یکنواخت بماند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) منبع canonical پیشنهادی برای همه APIهای AI
|
||||||
|
|
||||||
|
برای APIهای عمومی AI، منبع canonical باید این ترتیب باشد:
|
||||||
|
|
||||||
|
### 4.1) سنسورها
|
||||||
|
|
||||||
|
منبع اصلی:
|
||||||
|
|
||||||
|
- `farm_data.SensorData.sensor_payload`
|
||||||
|
|
||||||
|
اما نه به صورت خام. باید از مسیر زیر مصرف شود:
|
||||||
|
|
||||||
|
- assign سنسورها به `cluster/sub-block`
|
||||||
|
- میانگینگیری در سطح sub-block
|
||||||
|
- میانگینگیری مجدد در سطح block اصلی
|
||||||
|
- در صورت نیاز میانگینگیری در سطح کل مزرعه
|
||||||
|
|
||||||
|
### 4.2) ماهواره و remote sensing
|
||||||
|
|
||||||
|
منبع اصلی:
|
||||||
|
|
||||||
|
- `location_data.RemoteSensingRun`
|
||||||
|
- `location_data.AnalysisGridObservation`
|
||||||
|
- `location_data.RemoteSensingSubdivisionResult`
|
||||||
|
- `location_data.RemoteSensingClusterBlock`
|
||||||
|
- `location_data.RemoteSensingClusterAssignment`
|
||||||
|
|
||||||
|
و باز هم باید از مسیر زیر مصرف شود:
|
||||||
|
|
||||||
|
- میانگین متریک هر cluster/sub-block
|
||||||
|
- میانگین متریک هر block اصلی
|
||||||
|
- در صورت نیاز میانگین کل مزرعه
|
||||||
|
|
||||||
|
### 4.3) آبوهوا
|
||||||
|
|
||||||
|
منبع اصلی آبوهوا فعلاً cluster-based نیست و از اینجا میآید:
|
||||||
|
|
||||||
|
- `farm.weather_forecast`
|
||||||
|
- یا آخرین `center_location.weather_forecasts`
|
||||||
|
|
||||||
|
یعنی weather فعلاً location-center based است، نه cluster based.
|
||||||
|
|
||||||
|
### 4.4) source of truth نهایی برای APIهای عمومی
|
||||||
|
|
||||||
|
برای APIهایی که `farm_uuid` میگیرند، بهتر است `source of truth` نهایی یکی از این دو باشد:
|
||||||
|
|
||||||
|
- `get_farm_details(farm_uuid)` بعد از ارتقا به policy جدید
|
||||||
|
- یا یک service جدید مثل `build_ai_farm_snapshot(farm_uuid)`
|
||||||
|
|
||||||
|
این snapshot باید شامل این بخشها باشد:
|
||||||
|
|
||||||
|
- `farm_level_aggregated_metrics`
|
||||||
|
- `block_level_metrics`
|
||||||
|
- `sub_block_level_metrics`
|
||||||
|
- `weather`
|
||||||
|
- `plants`
|
||||||
|
- `irrigation_method`
|
||||||
|
- `source_metadata`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) وضعیت فعلی هر API
|
||||||
|
|
||||||
|
در این بخش برای هر API مشخص شده:
|
||||||
|
|
||||||
|
- آیا الان مبتنی بر میانگین سنسور کلاسترها و میانگین ماهوارهای کلاسترها هست یا نه
|
||||||
|
- اگر نیست، الان داده را از کجا میگیرد
|
||||||
|
- وضعیت انطباق آن با policy جدید
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.1) RAG
|
||||||
|
|
||||||
|
### `POST /api/rag/chat/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `نیمهمنطبق`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- متن خاک کاربر در `rag/user_data.py -> build_user_soil_text()`
|
||||||
|
- از `SensorData` و `center_location` میخواند
|
||||||
|
- از `build_farmer_block_aggregated_snapshot(...)` استفاده میکند
|
||||||
|
- از `build_location_block_satellite_snapshots(...)` هم برای blockهای اصلی استفاده میکند
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- برای summary کلی مزرعه، از aggregation مبتنی بر block استفاده میکند
|
||||||
|
- برای satellite و sensor summary تا حدی با policy شما سازگار است
|
||||||
|
- اما RAG chat هنوز یک contract رسمی و واحد برای `cluster mean only` ندارد و متن embed شده ممکن است ترکیبی از دادههای سطح farm و block باشد
|
||||||
|
|
||||||
|
نیاز به اصلاح:
|
||||||
|
|
||||||
|
- canonical snapshot باید صریحاً از `block mean` و `sub-block mean` ساخته شود
|
||||||
|
- prompt/context باید روشن بگوید که اعداد از `cluster averages` آمدهاند
|
||||||
|
|
||||||
|
### `POST /api/soile/anomaly-detection/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `غیرمنطبق مستقیم، منطبق غیرمستقیم`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- anomaly payload از request میآید
|
||||||
|
- اطلاعات مزرعه از `farm_data.services.get_farm_details()`
|
||||||
|
- سپس به `rag/services/soil_anomaly.py` داده میشود
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- خود anomaly ممکن است از هر منبعی آمده باشد و API آن را enforce نمیکند
|
||||||
|
- اما farm context از `get_farm_details()` میآید که فعلاً `latest_satellite + sensor_payload` را ترکیب میکند
|
||||||
|
- `get_farm_details()` هنوز خلاصه soil را از `build_location_satellite_snapshot(center_location)` میگیرد، نه از `build_farmer_block_aggregated_snapshot()`
|
||||||
|
|
||||||
|
نیاز به اصلاح:
|
||||||
|
|
||||||
|
- `get_farm_details()` باید خلاصه soil اصلی را از `farmer-level aggregated snapshot` بگیرد
|
||||||
|
- anomaly input هم اگر داخلی تولید میشود، باید بر پایه cluster mean باشد
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.2) Soile
|
||||||
|
|
||||||
|
### `POST /api/soile/health-summary/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `احتمالاً نیمهمنطبق`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- از `farm_data.context.load_farm_context()` و سرویسهای soil استفاده میکند
|
||||||
|
- `load_farm_context()` از `build_location_block_satellite_snapshots(location)` استفاده میکند
|
||||||
|
- بخشی از context block-based است
|
||||||
|
|
||||||
|
اما:
|
||||||
|
|
||||||
|
- اگر در summary نهایی از metrics خام سنسور یا latest weather/location استفاده شود، خروجی کاملاً cluster-mean-only نیست
|
||||||
|
|
||||||
|
نیاز به اصلاح:
|
||||||
|
|
||||||
|
- health summary باید صریحاً از `block_level` و `farm_level` aggregated metrics استفاده کند
|
||||||
|
- هیچ metric خام سنسور بدون عبور از منطق sub-block mean وارد پاسخ نشود
|
||||||
|
|
||||||
|
### `POST /api/soile/moisture-heatmap/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `منطبق نیست`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- heatmap ذاتاً spatial است و معمولاً از grid/cell یا location-level moisture data ساخته میشود
|
||||||
|
- برای heatmap، میانگینگیری کامل روی clusterها کافی نیست چون heatmap نیاز به توزیع مکانی دارد
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- این API نباید به خروجی تکعددی farm average تقلیل داده شود
|
||||||
|
- بلکه باید داده خام grid/cell یا cluster-level map را نگه دارد
|
||||||
|
|
||||||
|
policy صحیح:
|
||||||
|
|
||||||
|
- برای کارت summary یا average moisture بله، از cluster mean استفاده شود
|
||||||
|
- اما برای heatmap خودِ خروجی باید `cluster map` یا `grid map` باشد، نه صرفاً میانگین کل مزرعه
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.3) Weather
|
||||||
|
|
||||||
|
### `POST /api/weather/farm-card/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `غیرمنطبق`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- از `weather_forecast` مرتبط با `center_location`
|
||||||
|
- یا آخرین forecast همان location
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- weather فعلاً بر اساس clusterهای `location_data` نیست
|
||||||
|
- چون مدل weather cluster-based ندارد
|
||||||
|
|
||||||
|
نیاز به اصلاح:
|
||||||
|
|
||||||
|
- اگر قرار است cluster-based شود، باید weather در سطح block/sub-block تعریف شود
|
||||||
|
- در غیر این صورت، این API باید در سند بهعنوان `location-centered weather` ثبت شود
|
||||||
|
|
||||||
|
### `POST /api/weather/water-need-prediction/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `نیمهمنطبق`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- در `rag/services/water_need_prediction.py` از `get_farm_details(farm_uuid)` استفاده میشود
|
||||||
|
- همچنین از weather forecast مزرعه استفاده میکند
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- بخش خاک/سنسور میتواند از snapshot مزرعه بیاید
|
||||||
|
- اما چون `get_farm_details()` هنوز fully farmer-block-aggregated نیست، خروجی کامل منطبق نیست
|
||||||
|
- بخش weather هم location-level است
|
||||||
|
|
||||||
|
نیاز به اصلاح:
|
||||||
|
|
||||||
|
- خاک و ماهواره از farmer aggregated snapshot
|
||||||
|
- weather تا زمان توسعه مدل cluster-weather، بهصورت location-level باقی بماند ولی source آن شفاف اعلام شود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.4) Pest & Disease
|
||||||
|
|
||||||
|
### `POST /api/pest-disease/detect/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `غیرمنطبق مفهومی`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- ورودی اصلی: تصویر
|
||||||
|
- context کمکی: `farm_uuid` و داده مزرعه از `get_farm_details()`
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- هسته این API image-based است، نه cluster average based
|
||||||
|
- context مزرعه میتواند cluster-based شود، اما خروجی نهایی وابسته به تصویر است
|
||||||
|
|
||||||
|
policy صحیح:
|
||||||
|
|
||||||
|
- context مزرعه برای کمک به تشخیص از snapshot تجمیعی بیاید
|
||||||
|
- اما این API ذاتاً APIِ میانگینمحور نیست
|
||||||
|
|
||||||
|
### `POST /api/pest-disease/risk/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `نیمهمنطبق`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- `rag/services/pest_disease.py`
|
||||||
|
- اطلاعات مزرعه از `get_farm_details()`
|
||||||
|
- داده RAG و پایگاه دانش تخصصی
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- risk API باید از context مزرعه استفاده کند
|
||||||
|
- اگر `get_farm_details()` اصلاح شود، این API هم خودبهخود نزدیک به policy مطلوب میشود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.5) Irrigation
|
||||||
|
|
||||||
|
### `POST /api/irrigation/plan-from-text/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `غیرمنطبق ذاتی`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- ورودی اصلی: متن آزاد
|
||||||
|
- مدل parser متنی
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- این API parser است، نه تحلیل agronomic مبتنی بر sensor/satellite
|
||||||
|
- بنابراین لازم نیست خروجیاش بر پایه cluster average باشد
|
||||||
|
|
||||||
|
policy صحیح:
|
||||||
|
|
||||||
|
- فقط اگر farm context کمکی به parser اضافه شود، آن context باید از snapshot تجمیعی بیاید
|
||||||
|
|
||||||
|
### `POST /api/irrigation/recommend/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `نیمهمنطبق`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- از farm context و سرویس RAG irrigation استفاده میکند
|
||||||
|
- بخشی از سنسور را ممکن است مستقیماً از `sensor.sensor_payload` بخواند
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- اگر بعضی metricها مستقیم از payload خام خوانده شوند، با policy شما ناسازگار است
|
||||||
|
|
||||||
|
نیاز به اصلاح:
|
||||||
|
|
||||||
|
- recommendation فقط باید از aggregated block/sub-block metrics تغذیه شود
|
||||||
|
- fallback مستقیم به اولین سنسور یا payload خام باید حذف یا محدود شود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.6) Farm Parameters
|
||||||
|
|
||||||
|
### `POST /api/farm-data/parameters/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `خارج از دامنه این policy`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- این API داده را ایجاد/ویرایش میکند
|
||||||
|
- `farm_boundary` را میگیرد
|
||||||
|
- `center_location` را resolve میکند
|
||||||
|
- `sensor_payload` را ذخیره میکند
|
||||||
|
- weather/location sync را trigger میکند
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- این API تولیدکننده داده است، نه consumer تحلیلی
|
||||||
|
- بنابراین قرار نیست خروجی analytic آن بر پایه cluster mean باشد
|
||||||
|
|
||||||
|
اما نقش مهم:
|
||||||
|
|
||||||
|
- باید sensorها را طوری ذخیره کند که assignment به `cluster/sub-block` ممکن باشد
|
||||||
|
- اگر cluster_uuid یا sub_block_code در payload نیاید، aggregation دقیق محدود میشود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.7) Fertilization
|
||||||
|
|
||||||
|
### `POST /api/fertilization/plan-from-text/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `غیرمنطبق ذاتی`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- parser متن آزاد
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- مانند irrigation text parser، این API ماهیتاً cluster-average consumer نیست
|
||||||
|
|
||||||
|
### `POST /api/fertilization/recommend/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `نیمهمنطبق`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- context مزرعه و RAG fertilization
|
||||||
|
- معمولاً از `get_farm_details()` و contextهای وابسته استفاده میکند
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- با اصلاح منبع canonical مزرعه، این API هم میتواند کاملاً منطبق شود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.8) Crop Simulation
|
||||||
|
|
||||||
|
### `POST /api/crop-simulation/current-farm-chart/`
|
||||||
|
### `POST /api/crop-simulation/growth/`
|
||||||
|
### `POST /api/crop-simulation/harvest-prediction/`
|
||||||
|
### `GET /api/crop-simulation/yield-harvest-summary/`
|
||||||
|
### `POST /api/crop-simulation/yield-prediction/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `غیرمنطبق تا نیمهمنطبق`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- سرویسهای `crop_simulation/services.py`
|
||||||
|
- بخشی از دادهها از farm context و بخشی از سنسور یا weather فعلی میآیند
|
||||||
|
- در بعضی helperها متریکها ممکن است مستقیماً از `sensor_payload` یا propertyهای سنسور resolve شوند
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- با rule شما که گفته بودی برای `crop_simulation` باید از داده کل بلوکهای بزرگ استفاده شود، وضعیت فعلی هنوز کامل نیست
|
||||||
|
- چون منبع canonical شبیهسازی هنوز بهصورت صریح farmer-block aggregation enforced نشده
|
||||||
|
|
||||||
|
نیاز به اصلاح:
|
||||||
|
|
||||||
|
- ورودی تمام simulation endpointها باید از farm aggregated snapshot بیاید
|
||||||
|
- یعنی متریکهای soil moisture, temperature, ndvi, ndwi, slope, nitrogen, phosphorus, potassium از `cluster mean -> block mean -> farm mean` ساخته شوند
|
||||||
|
- weather فعلاً جداگانه location-level میماند مگر بعداً block-weather اضافه شود
|
||||||
|
|
||||||
|
### `GET /api/crop-simulation/growth/{task_id}/status/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `وابسته به منبع run اولیه`
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- این endpoint فقط status/result job را برمیگرداند
|
||||||
|
- اگر `growth` هنگام شروع از داده غیرمنطبق استفاده کرده باشد، status endpoint هم همان خروجی را reflect میکند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.9) Economy
|
||||||
|
|
||||||
|
### `POST /api/economy/overview/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `منطبق نیست و حتی منبع واقعی ندارد`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- `economy/services.py`
|
||||||
|
- پیام صریح دارد که منبع واقعی تنظیم نشده است
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- این API فعلاً نه cluster-based است و نه حتی data-backed
|
||||||
|
|
||||||
|
نیاز به اصلاح:
|
||||||
|
|
||||||
|
- اگر قرار است فعال شود، باید از snapshot مزرعه + هزینهها + yield prediction + irrigation/fertilization plan استفاده کند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.10) Farm Alerts
|
||||||
|
|
||||||
|
### `POST /api/farm-alerts/tracker/`
|
||||||
|
|
||||||
|
وضعیت فعلی: `نیمهمنطبق`
|
||||||
|
|
||||||
|
منبع داده فعلی:
|
||||||
|
|
||||||
|
- `farm_alerts/services.py`
|
||||||
|
- از `load_farm_context(farm_uuid)` و `get_farm_details(farm_uuid)` استفاده میکند
|
||||||
|
|
||||||
|
نتیجه:
|
||||||
|
|
||||||
|
- چون farm context بخشی از snapshotهای block-based را میخواند، تا حدی با policy سازگار است
|
||||||
|
- اما اگر context نهایی هنوز metric خام یا latest non-aggregated را ترجیح بدهد، کامل منطبق نیست
|
||||||
|
|
||||||
|
نیاز به اصلاح:
|
||||||
|
|
||||||
|
- همه alert ruleها باید روی block/farm aggregated metrics سوار شوند
|
||||||
|
- در صورت نیاز، هشدارهای spatial باید روی cluster-level breakdown هم نمایش داده شوند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) جمعبندی سریع انطباق APIها
|
||||||
|
|
||||||
|
### APIهایی که ماهیتاً باید cluster-aggregated باشند
|
||||||
|
|
||||||
|
اینها باید به policy جدید مهاجرت کنند:
|
||||||
|
|
||||||
|
- `POST /api/rag/chat/`
|
||||||
|
- `POST /api/soile/anomaly-detection/`
|
||||||
|
- `POST /api/soile/health-summary/`
|
||||||
|
- `POST /api/weather/water-need-prediction/`
|
||||||
|
- `POST /api/pest-disease/risk/`
|
||||||
|
- `POST /api/irrigation/recommend/`
|
||||||
|
- `POST /api/fertilization/recommend/`
|
||||||
|
- `POST /api/crop-simulation/current-farm-chart/`
|
||||||
|
- `POST /api/crop-simulation/growth/`
|
||||||
|
- `POST /api/crop-simulation/harvest-prediction/`
|
||||||
|
- `GET /api/crop-simulation/yield-harvest-summary/`
|
||||||
|
- `POST /api/crop-simulation/yield-prediction/`
|
||||||
|
- `POST /api/farm-alerts/tracker/`
|
||||||
|
|
||||||
|
### APIهایی که ماهیتاً parser/input/image/weather هستند و کامل cluster-based نیستند
|
||||||
|
|
||||||
|
- `POST /api/pest-disease/detect/` -> image-first
|
||||||
|
- `POST /api/irrigation/plan-from-text/` -> text parser
|
||||||
|
- `POST /api/fertilization/plan-from-text/` -> text parser
|
||||||
|
- `POST /api/farm-data/parameters/` -> data ingestion/upsert
|
||||||
|
- `POST /api/weather/farm-card/` -> location-centered weather
|
||||||
|
- `POST /api/economy/overview/` -> فعلاً منبع واقعی ندارد
|
||||||
|
|
||||||
|
### APIهایی که spatial map هستند و نباید صرفاً average شوند
|
||||||
|
|
||||||
|
- `POST /api/soile/moisture-heatmap/`
|
||||||
|
|
||||||
|
این API باید cluster/grid-aware بماند، نه فقط average-based.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) مهمترین mismatch فعلی در کد
|
||||||
|
|
||||||
|
مهمترین mismatch فعلی این است که `farm_data.services.get_farm_details()` برای `soil.resolved_metrics` این کار را میکند:
|
||||||
|
|
||||||
|
- `latest_satellite = build_location_satellite_snapshot(center_location)`
|
||||||
|
- سپس `latest_satellite.resolved_metrics` را به عنوان soil_metrics میگیرد
|
||||||
|
|
||||||
|
این یعنی:
|
||||||
|
|
||||||
|
- خلاصه اصلی soil بر اساس یک snapshot location/block واحد ساخته میشود
|
||||||
|
- نه بر اساس `build_farmer_block_aggregated_snapshot(center_location, sensor_payload=...)`
|
||||||
|
|
||||||
|
در حالی که برای policy موردنظر شما بهتر است این کار انجام شود:
|
||||||
|
|
||||||
|
- `farm_level_snapshot = build_farmer_block_aggregated_snapshot(...)`
|
||||||
|
- `soil.resolved_metrics = farm_level_snapshot.resolved_metrics`
|
||||||
|
- `soil.satellite_snapshots = block-level snapshots`
|
||||||
|
- در صورت نیاز `sub_block snapshots` هم در context بمانند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) پیشنهاد ساختار استاندارد پاسخ داده برای AI
|
||||||
|
|
||||||
|
برای همه سرویسهای AI بهتر است یک snapshot واحد با این ساختار وجود داشته باشد:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": "...",
|
||||||
|
"aggregation_policy": {
|
||||||
|
"sensor": "cluster_mean_then_block_mean_then_farm_mean",
|
||||||
|
"satellite": "cluster_mean_then_block_mean_then_farm_mean",
|
||||||
|
"weather": "center_location_latest_forecast",
|
||||||
|
"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"
|
||||||
|
},
|
||||||
|
"farm_metrics": {},
|
||||||
|
"block_metrics": [],
|
||||||
|
"sub_block_metrics": [],
|
||||||
|
"weather": {},
|
||||||
|
"plants": [],
|
||||||
|
"irrigation_method": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
مزیت این طراحی:
|
||||||
|
|
||||||
|
- همه APIها source of truth یکسان دارند
|
||||||
|
- اختلاف بین RAG، crop simulation، irrigation و alerts کم میشود
|
||||||
|
- تستپذیری سادهتر میشود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) نتیجه نهایی
|
||||||
|
|
||||||
|
### الان کدام APIها دقیقاً مطابق خواسته شما نیستند؟
|
||||||
|
|
||||||
|
تقریباً همه APIهای تحلیلی هنوز `کاملاً` مطابق policy شما نیستند، چون منبع canonical یکپارچهای که صریحاً بر پایه `cluster mean sensor + cluster mean satellite` باشد هنوز همهجا enforce نشده است.
|
||||||
|
|
||||||
|
بیشترین فاصله با policy مطلوب در این بخشهاست:
|
||||||
|
|
||||||
|
- `crop_simulation`
|
||||||
|
- `irrigation/recommend`
|
||||||
|
- `fertilization/recommend`
|
||||||
|
- `weather/farm-card`
|
||||||
|
- `economy/overview`
|
||||||
|
- بخشی از `soile` و `farm_alerts`
|
||||||
|
|
||||||
|
### الان کدام APIها از همین حالا تا حدی نزدیک هستند؟
|
||||||
|
|
||||||
|
- `rag/chat`
|
||||||
|
- `farm_alerts/tracker`
|
||||||
|
- `soile/health-summary`
|
||||||
|
- `pest-disease/risk`
|
||||||
|
|
||||||
|
چون بهصورت مستقیم یا غیرمستقیم از snapshotهای block-based و `get_farm_details()` استفاده میکنند.
|
||||||
|
|
||||||
|
### الان کدام APIها ماهیتاً نباید صرفاً average-based شوند؟
|
||||||
|
|
||||||
|
- `soile/moisture-heatmap`
|
||||||
|
- `pest-disease/detect`
|
||||||
|
- parserهای `plan-from-text`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) پیشنهاد اجرای فنی در کد
|
||||||
|
|
||||||
|
ترتیب پیشنهادی برای پیادهسازی:
|
||||||
|
|
||||||
|
1. `get_farm_details()` را به `farmer aggregated snapshot` مهاجرت بده.
|
||||||
|
2. یک service جدید مثل `build_ai_farm_snapshot()` بساز.
|
||||||
|
3. default block policy را به `1 main block + 1 default sub-block` ارتقا بده.
|
||||||
|
4. همه APIهای تحلیلی را وادار کن فقط از snapshot جدید بخوانند.
|
||||||
|
5. برای APIهای spatial مثل heatmap، علاوه بر average summary، خروجی cluster/grid breakdown نگه دار.
|
||||||
|
|
||||||
@@ -88,7 +88,8 @@ if [ "${SKIP_MIGRATE}" != "1" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then
|
if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then
|
||||||
echo "DEVELOP is set. Seeding demo plant, weather_data, and farm_data..."
|
echo "DEVELOP is set. Seeding demo location_data, plant, weather_data, and farm_data..."
|
||||||
|
run_cmd python manage.py seed_location_data
|
||||||
run_cmd python manage.py seed_plants
|
run_cmd python manage.py seed_plants
|
||||||
run_cmd python manage.py seed_weather_data
|
run_cmd python manage.py seed_weather_data
|
||||||
run_cmd python manage.py seed_farm_data
|
run_cmd python manage.py seed_farm_data
|
||||||
@@ -96,7 +97,9 @@ if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Checking openEO authentication..."
|
echo "Checking openEO authentication..."
|
||||||
run_cmd python manage.py verify_openeo_auth --skip-if-unconfigured
|
if ! run_cmd python manage.py verify_openeo_auth --skip-if-unconfigured; then
|
||||||
|
echo "openEO authentication failed; continuing startup with degraded openEO-dependent features." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Collecting static files..."
|
echo "Collecting static files..."
|
||||||
run_cmd python manage.py collectstatic --noinput
|
run_cmd python manage.py collectstatic --noinput
|
||||||
|
|||||||
@@ -521,13 +521,95 @@ def _build_alert_stats(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_metric(ai_snapshot: dict[str, Any] | None, metric_name: str) -> float | None:
|
||||||
|
if not isinstance(ai_snapshot, dict):
|
||||||
|
return None
|
||||||
|
farm_metrics = ai_snapshot.get("farm_metrics") or {}
|
||||||
|
resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {}
|
||||||
|
if not isinstance(resolved_metrics, dict):
|
||||||
|
return None
|
||||||
|
return safe_number(resolved_metrics.get(metric_name), None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_weather(ai_snapshot: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if not isinstance(ai_snapshot, dict):
|
||||||
|
return {}
|
||||||
|
weather = ai_snapshot.get("weather") or {}
|
||||||
|
forecast = weather.get("forecast") if isinstance(weather, dict) else None
|
||||||
|
return forecast if isinstance(forecast, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _block_metric_alerts(ai_snapshot: dict[str, Any] | None, sensor_id: str) -> list[dict[str, Any]]:
|
||||||
|
alerts: list[dict[str, Any]] = []
|
||||||
|
if not isinstance(ai_snapshot, dict):
|
||||||
|
return alerts
|
||||||
|
for block in ai_snapshot.get("block_metrics") or []:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
block_code = str(block.get("block_code") or "default-block")
|
||||||
|
metrics = block.get("resolved_metrics") or {}
|
||||||
|
moisture = safe_number(metrics.get("soil_moisture"), None)
|
||||||
|
if moisture is not None and moisture < 25:
|
||||||
|
alerts.append(
|
||||||
|
_make_alert(
|
||||||
|
metric_type="moisture",
|
||||||
|
current_value=moisture,
|
||||||
|
threshold_value=25.0,
|
||||||
|
severity="warning" if moisture >= 18 else "danger",
|
||||||
|
duration_hours=1.0,
|
||||||
|
timestamp=_now(),
|
||||||
|
sensor_id=sensor_id,
|
||||||
|
zone_id=block_code,
|
||||||
|
direction="below",
|
||||||
|
metadata={
|
||||||
|
"evaluation_level": "block",
|
||||||
|
"affected_blocks": [block_code],
|
||||||
|
"source": "block_metrics",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _sub_block_support(ai_snapshot: dict[str, Any] | None, metric_type: str) -> list[dict[str, Any]]:
|
||||||
|
evidence: list[dict[str, Any]] = []
|
||||||
|
if not isinstance(ai_snapshot, dict):
|
||||||
|
return evidence
|
||||||
|
metric_key = {
|
||||||
|
"moisture": "soil_moisture",
|
||||||
|
"ph": "soil_ph",
|
||||||
|
"ec": "electrical_conductivity",
|
||||||
|
}.get(metric_type)
|
||||||
|
if not metric_key:
|
||||||
|
return evidence
|
||||||
|
for sub_block in ai_snapshot.get("sub_block_metrics") or []:
|
||||||
|
if not isinstance(sub_block, dict):
|
||||||
|
continue
|
||||||
|
metrics = sub_block.get("resolved_metrics") or {}
|
||||||
|
value = metrics.get(metric_key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
evidence.append(
|
||||||
|
{
|
||||||
|
"block_code": sub_block.get("block_code") or "default-block",
|
||||||
|
"sub_block_code": sub_block.get("sub_block_code") or "default-sub-block",
|
||||||
|
"metric": metric_key,
|
||||||
|
"value": round(float(value), 2),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return evidence
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||||
context = context or {}
|
context = context or {}
|
||||||
sensor = context.get("sensor")
|
ai_snapshot = (ai_bundle or {}).get("ai_snapshot") if isinstance(ai_bundle, dict) else None
|
||||||
forecasts = context.get("forecasts", [])
|
forecasts = context.get("forecasts", [])
|
||||||
history = context.get("history", [])
|
|
||||||
|
|
||||||
if sensor is None:
|
if not isinstance(ai_snapshot, dict):
|
||||||
return {
|
return {
|
||||||
"totalAlerts": 0,
|
"totalAlerts": 0,
|
||||||
"alerts": [],
|
"alerts": [],
|
||||||
@@ -537,14 +619,116 @@ def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bu
|
|||||||
"prioritizedAlertSummaries": [],
|
"prioritizedAlertSummaries": [],
|
||||||
"recommendedOperationalActions": [],
|
"recommendedOperationalActions": [],
|
||||||
"humanReadableExplanations": [],
|
"humanReadableExplanations": [],
|
||||||
|
"source_metadata": {"status": "missing", "fallback": "no_ai_snapshot"},
|
||||||
}
|
}
|
||||||
|
|
||||||
alerts = []
|
alerts = []
|
||||||
alerts.extend(_detect_moisture_alert(sensor, history, sensor_id))
|
|
||||||
alerts.extend(_detect_ph_alert(sensor, history, sensor_id))
|
moisture = _snapshot_metric(ai_snapshot, "soil_moisture")
|
||||||
alerts.extend(_detect_ec_alert(sensor, history, sensor_id))
|
if moisture is not None and moisture < 25:
|
||||||
alerts.extend(_detect_frost_alert(forecasts, sensor_id))
|
alerts.append(
|
||||||
alerts.extend(_detect_fungal_risk(sensor, forecasts, history, sensor_id))
|
_make_alert(
|
||||||
|
metric_type="moisture",
|
||||||
|
current_value=moisture,
|
||||||
|
threshold_value=25.0,
|
||||||
|
severity="warning" if moisture >= 18 else "danger",
|
||||||
|
duration_hours=1.0,
|
||||||
|
timestamp=_now(),
|
||||||
|
sensor_id=sensor_id,
|
||||||
|
direction="below",
|
||||||
|
metadata={
|
||||||
|
"evaluation_level": "farm",
|
||||||
|
"affected_blocks": [item.get("block_code") for item in (ai_snapshot.get("block_metrics") or [])],
|
||||||
|
"supporting_sub_blocks": _sub_block_support(ai_snapshot, "moisture"),
|
||||||
|
"source": "farm_metrics",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
soil_ph = _snapshot_metric(ai_snapshot, "soil_ph")
|
||||||
|
if soil_ph is not None and not (6.0 <= soil_ph <= 7.8):
|
||||||
|
alerts.append(
|
||||||
|
_make_alert(
|
||||||
|
metric_type="ph",
|
||||||
|
current_value=soil_ph,
|
||||||
|
threshold_value="6.0-7.8",
|
||||||
|
severity="warning" if 5.5 <= soil_ph <= 8.2 else "danger",
|
||||||
|
duration_hours=1.0,
|
||||||
|
timestamp=_now(),
|
||||||
|
sensor_id=sensor_id,
|
||||||
|
direction="below" if soil_ph < 6.0 else "above",
|
||||||
|
metadata={
|
||||||
|
"evaluation_level": "farm",
|
||||||
|
"supporting_sub_blocks": _sub_block_support(ai_snapshot, "ph"),
|
||||||
|
"source": "farm_metrics",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ec = _snapshot_metric(ai_snapshot, "electrical_conductivity")
|
||||||
|
if ec is not None and ec > 2.5:
|
||||||
|
alerts.append(
|
||||||
|
_make_alert(
|
||||||
|
metric_type="ec",
|
||||||
|
current_value=ec,
|
||||||
|
threshold_value=2.5,
|
||||||
|
severity="warning" if ec <= 3.2 else "danger",
|
||||||
|
duration_hours=1.0,
|
||||||
|
timestamp=_now(),
|
||||||
|
sensor_id=sensor_id,
|
||||||
|
direction="above",
|
||||||
|
metadata={
|
||||||
|
"evaluation_level": "farm",
|
||||||
|
"supporting_sub_blocks": _sub_block_support(ai_snapshot, "ec"),
|
||||||
|
"source": "farm_metrics",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
weather = _snapshot_weather(ai_snapshot)
|
||||||
|
if weather:
|
||||||
|
temp_min = safe_number(weather.get("temperature_min"), None)
|
||||||
|
if temp_min is not None and temp_min < 0:
|
||||||
|
alerts.append(
|
||||||
|
_make_alert(
|
||||||
|
metric_type="temperature",
|
||||||
|
current_value=temp_min,
|
||||||
|
threshold_value=0.0,
|
||||||
|
severity="warning" if temp_min >= -2 else "danger",
|
||||||
|
duration_hours=24.0,
|
||||||
|
timestamp=_now(),
|
||||||
|
sensor_id=sensor_id,
|
||||||
|
direction="below",
|
||||||
|
metadata={
|
||||||
|
"evaluation_level": "farm",
|
||||||
|
"source": "weather_forecast",
|
||||||
|
"weather_policy": "center_location_latest_forecast",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
humidity = safe_number(weather.get("humidity_mean"), None)
|
||||||
|
if humidity is not None and moisture is not None and humidity > 75 and moisture > 60:
|
||||||
|
alerts.append(
|
||||||
|
_make_alert(
|
||||||
|
metric_type="fungal_risk",
|
||||||
|
current_value=humidity,
|
||||||
|
threshold_value=75.0,
|
||||||
|
severity="warning" if humidity <= 85 else "danger",
|
||||||
|
duration_hours=24.0,
|
||||||
|
timestamp=_now(),
|
||||||
|
sensor_id=sensor_id,
|
||||||
|
direction="above",
|
||||||
|
metadata={
|
||||||
|
"evaluation_level": "farm",
|
||||||
|
"supporting_sub_blocks": _sub_block_support(ai_snapshot, "moisture"),
|
||||||
|
"source": "farm_metrics+weather_forecast",
|
||||||
|
"soil_moisture": round(moisture, 2),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
alerts.extend(_block_metric_alerts(ai_snapshot, sensor_id))
|
||||||
|
|
||||||
ordered_alerts = _sort_alerts(alerts)
|
ordered_alerts = _sort_alerts(alerts)
|
||||||
clusters = _build_clusters(ordered_alerts)
|
clusters = _build_clusters(ordered_alerts)
|
||||||
@@ -559,4 +743,9 @@ def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bu
|
|||||||
"prioritizedAlertSummaries": [alert["summary"] for alert in ordered_alerts],
|
"prioritizedAlertSummaries": [alert["summary"] for alert in ordered_alerts],
|
||||||
"recommendedOperationalActions": [alert["recommended_action"] for alert in ordered_alerts],
|
"recommendedOperationalActions": [alert["recommended_action"] for alert in ordered_alerts],
|
||||||
"humanReadableExplanations": [alert["explanation"] for alert in ordered_alerts],
|
"humanReadableExplanations": [alert["explanation"] for alert in ordered_alerts],
|
||||||
|
"source_metadata": {
|
||||||
|
"farm_metrics": (ai_snapshot.get("source_metadata") or {}).get("farm_metrics", {}),
|
||||||
|
"weather": ((ai_snapshot.get("weather") or {}).get("source_metadata") or {}),
|
||||||
|
"default_block_policy": (ai_snapshot.get("aggregation_policy") or {}).get("default_block_policy"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ from typing import Any
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
|
||||||
from farm_data.services import get_farm_details
|
|
||||||
from farm_data.context import load_farm_context
|
from farm_data.context import load_farm_context
|
||||||
|
from farm_data.services import build_ai_farm_snapshot
|
||||||
from rag.api_provider import get_chat_client
|
from rag.api_provider import get_chat_client
|
||||||
from rag.chat import (
|
from rag.chat import (
|
||||||
_complete_audit_log,
|
_complete_audit_log,
|
||||||
@@ -156,12 +156,18 @@ def _build_structured_context(
|
|||||||
if context is None:
|
if context is None:
|
||||||
raise ValueError("farm_uuid نامعتبر است یا اطلاعات هشدار مزرعه پیدا نشد.")
|
raise ValueError("farm_uuid نامعتبر است یا اطلاعات هشدار مزرعه پیدا نشد.")
|
||||||
|
|
||||||
tracker = build_farm_alerts_tracker(sensor_id=farm_uuid, context=context, ai_bundle=None)
|
ai_snapshot = build_ai_farm_snapshot(farm_uuid)
|
||||||
|
tracker = build_farm_alerts_tracker(
|
||||||
|
sensor_id=farm_uuid,
|
||||||
|
context=context,
|
||||||
|
ai_bundle={"ai_snapshot": ai_snapshot},
|
||||||
|
)
|
||||||
structured = {
|
structured = {
|
||||||
"farm_profile": _farm_profile(context, farm_uuid),
|
"farm_profile": _farm_profile(context, farm_uuid),
|
||||||
"tracker": tracker,
|
"tracker": tracker,
|
||||||
"forecasts": _forecast_summary(context),
|
"forecasts": _forecast_summary(context),
|
||||||
"incoming_alerts": _normalize_incoming_alerts(incoming_alerts),
|
"incoming_alerts": _normalize_incoming_alerts(incoming_alerts),
|
||||||
|
"ai_snapshot_source_metadata": (ai_snapshot or {}).get("source_metadata", {}),
|
||||||
}
|
}
|
||||||
return context, structured
|
return context, structured
|
||||||
|
|
||||||
@@ -321,7 +327,7 @@ def _llm_response(
|
|||||||
) -> tuple[dict[str, Any], str, str]:
|
) -> tuple[dict[str, Any], str, str]:
|
||||||
cfg = load_rag_config()
|
cfg = load_rag_config()
|
||||||
service, service_cfg, model, client = _build_service_config(cfg, service_id)
|
service, service_cfg, model, client = _build_service_config(cfg, service_id)
|
||||||
farm_details = get_farm_details(farm_uuid)
|
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||||
rag_context = build_rag_context(
|
rag_context = build_rag_context(
|
||||||
query=query,
|
query=query,
|
||||||
sensor_uuid=farm_uuid,
|
sensor_uuid=farm_uuid,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from farm_alerts.alerts_tracker import build_farm_alerts_tracker
|
||||||
|
|
||||||
|
|
||||||
|
class FarmAlertsTrackerCanonicalTests(SimpleTestCase):
|
||||||
|
def test_whole_farm_alert_uses_aggregated_metrics(self):
|
||||||
|
ai_snapshot = {
|
||||||
|
"farm_metrics": {"resolved_metrics": {"soil_moisture": 16.0}},
|
||||||
|
"block_metrics": [{"block_code": "block-1", "resolved_metrics": {"soil_moisture": 14.0}}],
|
||||||
|
"sub_block_metrics": [
|
||||||
|
{"block_code": "block-1", "sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 12.0}}
|
||||||
|
],
|
||||||
|
"weather": {"forecast": {"temperature_min": 8.0, "humidity_mean": 60.0}, "source_metadata": {}},
|
||||||
|
"source_metadata": {"farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"}},
|
||||||
|
"aggregation_policy": {"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker = build_farm_alerts_tracker(
|
||||||
|
sensor_id="farm-1",
|
||||||
|
context={"forecasts": []},
|
||||||
|
ai_bundle={"ai_snapshot": ai_snapshot},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(tracker["totalAlerts"], 2)
|
||||||
|
self.assertEqual(tracker["alerts"][0]["metadata"]["evaluation_level"], "farm")
|
||||||
|
self.assertEqual(tracker["alerts"][0]["metadata"]["source"], "farm_metrics")
|
||||||
|
self.assertEqual(tracker["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||||
|
|
||||||
|
def test_block_specific_alert_includes_affected_block(self):
|
||||||
|
ai_snapshot = {
|
||||||
|
"farm_metrics": {"resolved_metrics": {"soil_moisture": 30.0}},
|
||||||
|
"block_metrics": [
|
||||||
|
{"block_code": "block-1", "resolved_metrics": {"soil_moisture": 31.0}},
|
||||||
|
{"block_code": "block-2", "resolved_metrics": {"soil_moisture": 17.0}},
|
||||||
|
],
|
||||||
|
"sub_block_metrics": [
|
||||||
|
{"block_code": "block-2", "sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 15.0}}
|
||||||
|
],
|
||||||
|
"weather": {"forecast": {}, "source_metadata": {}},
|
||||||
|
"source_metadata": {"farm_metrics": {}},
|
||||||
|
"aggregation_policy": {"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker = build_farm_alerts_tracker(
|
||||||
|
sensor_id="farm-1",
|
||||||
|
context={"forecasts": []},
|
||||||
|
ai_bundle={"ai_snapshot": ai_snapshot},
|
||||||
|
)
|
||||||
|
|
||||||
|
block_alerts = [alert for alert in tracker["alerts"] if alert.get("zone_id") == "block-2"]
|
||||||
|
self.assertEqual(len(block_alerts), 1)
|
||||||
|
self.assertEqual(block_alerts[0]["metadata"]["evaluation_level"], "block")
|
||||||
|
self.assertEqual(block_alerts[0]["metadata"]["affected_blocks"], ["block-2"])
|
||||||
|
|
||||||
|
def test_default_block_sub_block_policy_is_reported(self):
|
||||||
|
ai_snapshot = {
|
||||||
|
"farm_metrics": {"resolved_metrics": {"soil_moisture": 22.0}},
|
||||||
|
"block_metrics": [{"block_code": "default-block", "resolved_metrics": {"soil_moisture": 22.0}}],
|
||||||
|
"sub_block_metrics": [
|
||||||
|
{"block_code": "default-block", "sub_block_code": "default-sub-block", "resolved_metrics": {"soil_moisture": 22.0}}
|
||||||
|
],
|
||||||
|
"weather": {"forecast": {}, "source_metadata": {}},
|
||||||
|
"source_metadata": {"farm_metrics": {}},
|
||||||
|
"aggregation_policy": {"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker = build_farm_alerts_tracker(
|
||||||
|
sensor_id="farm-1",
|
||||||
|
context={"forecasts": []},
|
||||||
|
ai_bundle={"ai_snapshot": ai_snapshot},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tracker["source_metadata"]["default_block_policy"],
|
||||||
|
"1_main_block + 1_default_sub_block_when_missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_snapshot_uses_explicit_fallback_metadata(self):
|
||||||
|
tracker = build_farm_alerts_tracker(sensor_id="farm-1", context={"forecasts": []}, ai_bundle={})
|
||||||
|
|
||||||
|
self.assertEqual(tracker["totalAlerts"], 0)
|
||||||
|
self.assertEqual(tracker["source_metadata"]["fallback"], "no_ai_snapshot")
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_devices_from_sensor_payload(apps, schema_editor):
|
||||||
|
SensorData = apps.get_model("sensor_data", "SensorData")
|
||||||
|
Device = apps.get_model("sensor_data", "Device")
|
||||||
|
|
||||||
|
for farm in SensorData.objects.all().iterator():
|
||||||
|
sensor_payload = farm.sensor_payload if isinstance(farm.sensor_payload, dict) else {}
|
||||||
|
location_id = getattr(farm, "center_location_id", None)
|
||||||
|
if location_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for sensor_name, payload in sensor_payload.items():
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cluster_uuid = None
|
||||||
|
raw_cluster_uuid = payload.get("cluster_uuid")
|
||||||
|
if raw_cluster_uuid not in (None, ""):
|
||||||
|
try:
|
||||||
|
cluster_uuid = uuid.UUID(str(raw_cluster_uuid))
|
||||||
|
except (TypeError, ValueError, AttributeError):
|
||||||
|
cluster_uuid = None
|
||||||
|
|
||||||
|
Device.objects.update_or_create(
|
||||||
|
farm_id=farm.pk,
|
||||||
|
sensor_name=sensor_name,
|
||||||
|
defaults={
|
||||||
|
"location_id": location_id,
|
||||||
|
"payload": payload,
|
||||||
|
"cluster_uuid": cluster_uuid,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("location_data", "0019_cluster_block_centers"),
|
||||||
|
("sensor_data", "0012_plant_catalog_snapshot_and_assignment"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Device",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("sensor_name", models.CharField(db_index=True, help_text='نام سنسور مثل "sensor-7-1"', max_length=64)),
|
||||||
|
("payload", models.JSONField(blank=True, default=dict, help_text="payload همان سنسور")),
|
||||||
|
("cluster_uuid", models.UUIDField(blank=True, db_index=True, help_text="uuid کلاستر داخل location برای این سنسور", null=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("farm", models.ForeignKey(db_column="farm_uuid", on_delete=django.db.models.deletion.CASCADE, related_name="devices", to="sensor_data.sensordata")),
|
||||||
|
("location", models.ForeignKey(db_column="location_id", help_text="location مرتبط با این device", on_delete=django.db.models.deletion.CASCADE, related_name="devices", to="location_data.soillocation")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "device",
|
||||||
|
"verbose_name_plural": "devices",
|
||||||
|
"db_table": "farm_data_device",
|
||||||
|
"ordering": ["sensor_name", "id"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="device",
|
||||||
|
constraint=models.UniqueConstraint(fields=("farm", "sensor_name"), name="farm_data_unique_device_per_farm_sensor"),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
backfill_devices_from_sensor_payload,
|
||||||
|
migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -169,6 +169,57 @@ class SensorData(SensorPayloadMixin, models.Model):
|
|||||||
return [assignment.plant for assignment in self.plant_assignments.select_related("plant").order_by("position", "id")]
|
return [assignment.plant for assignment in self.plant_assignments.select_related("plant").order_by("position", "id")]
|
||||||
|
|
||||||
|
|
||||||
|
class Device(models.Model):
|
||||||
|
"""نسخه نرمالشده هر سنسور داخل farm_data_sensordata."""
|
||||||
|
|
||||||
|
farm = models.ForeignKey(
|
||||||
|
SensorData,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="devices",
|
||||||
|
db_column="farm_uuid",
|
||||||
|
)
|
||||||
|
location = models.ForeignKey(
|
||||||
|
"location_data.SoilLocation",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="devices",
|
||||||
|
db_column="location_id",
|
||||||
|
help_text="location مرتبط با این device",
|
||||||
|
)
|
||||||
|
sensor_name = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
db_index=True,
|
||||||
|
help_text='نام سنسور مثل "sensor-7-1"',
|
||||||
|
)
|
||||||
|
payload = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
help_text="payload همان سنسور",
|
||||||
|
)
|
||||||
|
cluster_uuid = models.UUIDField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="uuid کلاستر داخل location برای این سنسور",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "farm_data_device"
|
||||||
|
ordering = ["sensor_name", "id"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["farm", "sensor_name"],
|
||||||
|
name="farm_data_unique_device_per_farm_sensor",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
verbose_name = "device"
|
||||||
|
verbose_name_plural = "devices"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.farm_id}::{self.sensor_name}"
|
||||||
|
|
||||||
|
|
||||||
class PlantCatalogSnapshot(models.Model):
|
class PlantCatalogSnapshot(models.Model):
|
||||||
"""
|
"""
|
||||||
کپی خواندنی از کاتالوگ گیاه Backend برای مصرف ماژولهای AI.
|
کپی خواندنی از کاتالوگ گیاه Backend برای مصرف ماژولهای AI.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from numbers import Number
|
from numbers import Number
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -17,13 +18,14 @@ from location_data.block_subdivision import create_or_get_block_subdivision
|
|||||||
from location_data.models import BlockSubdivision, SoilLocation
|
from location_data.models import BlockSubdivision, SoilLocation
|
||||||
from location_data.satellite_snapshot import (
|
from location_data.satellite_snapshot import (
|
||||||
build_block_layout_metric_summary,
|
build_block_layout_metric_summary,
|
||||||
|
build_farmer_block_aggregated_snapshot,
|
||||||
build_location_block_satellite_snapshots,
|
build_location_block_satellite_snapshots,
|
||||||
build_location_satellite_snapshot,
|
|
||||||
)
|
)
|
||||||
from irrigation.serializers import IrrigationMethodSerializer
|
from irrigation.serializers import IrrigationMethodSerializer
|
||||||
from weather.models import WeatherForecast
|
from weather.models import WeatherForecast
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
|
Device,
|
||||||
FarmPlantAssignment,
|
FarmPlantAssignment,
|
||||||
ParameterUpdateLog,
|
ParameterUpdateLog,
|
||||||
PlantCatalogSnapshot,
|
PlantCatalogSnapshot,
|
||||||
@@ -431,6 +433,45 @@ def sync_sensor_parameters_from_payload(sensor_payload: dict | None) -> list[Sen
|
|||||||
return synced_parameters
|
return synced_parameters
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cluster_uuid(value: object) -> uuid.UUID | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(value))
|
||||||
|
except (TypeError, ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sync_devices_from_sensor_data(farm: SensorData) -> list[Device]:
|
||||||
|
sensor_payload = farm.sensor_payload if isinstance(farm.sensor_payload, dict) else {}
|
||||||
|
location = farm.center_location
|
||||||
|
synced_devices: list[Device] = []
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
active_sensor_names: list[str] = []
|
||||||
|
for sensor_name, payload in sensor_payload.items():
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
active_sensor_names.append(sensor_name)
|
||||||
|
device, _created = Device.objects.update_or_create(
|
||||||
|
farm=farm,
|
||||||
|
sensor_name=sensor_name,
|
||||||
|
defaults={
|
||||||
|
"location": location,
|
||||||
|
"payload": payload,
|
||||||
|
"cluster_uuid": _parse_cluster_uuid(payload.get("cluster_uuid")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
synced_devices.append(device)
|
||||||
|
|
||||||
|
stale_devices = Device.objects.filter(farm=farm)
|
||||||
|
if active_sensor_names:
|
||||||
|
stale_devices = stale_devices.exclude(sensor_name__in=active_sensor_names)
|
||||||
|
stale_devices.delete()
|
||||||
|
|
||||||
|
return synced_devices
|
||||||
|
|
||||||
|
|
||||||
def get_sensor_parameter_catalog(sensor_payload: dict | None = None) -> dict[str, list[dict[str, object]]]:
|
def get_sensor_parameter_catalog(sensor_payload: dict | None = None) -> dict[str, list[dict[str, object]]]:
|
||||||
parameter_queryset = SensorParameter.objects.order_by("sensor_key", "code")
|
parameter_queryset = SensorParameter.objects.order_by("sensor_key", "code")
|
||||||
if sensor_payload and isinstance(sensor_payload, dict):
|
if sensor_payload and isinstance(sensor_payload, dict):
|
||||||
@@ -464,24 +505,10 @@ def get_farm_details(farm_uuid: str):
|
|||||||
center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
|
center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
|
||||||
)
|
)
|
||||||
|
|
||||||
latest_satellite = build_location_satellite_snapshot(center_location)
|
soil_snapshot = _build_farm_soil_snapshot(
|
||||||
block_metric_snapshots = build_location_block_satellite_snapshots(
|
|
||||||
center_location,
|
center_location,
|
||||||
sensor_payload=farm.sensor_payload,
|
sensor_payload=farm.sensor_payload,
|
||||||
)
|
)
|
||||||
if all(
|
|
||||||
snapshot.get("status") == "missing" and not snapshot.get("resolved_metrics")
|
|
||||||
for snapshot in block_metric_snapshots
|
|
||||||
):
|
|
||||||
block_metric_snapshots = []
|
|
||||||
soil_metrics = dict(latest_satellite.get("resolved_metrics") or {})
|
|
||||||
sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(farm.sensor_payload)
|
|
||||||
|
|
||||||
resolved_metrics = dict(soil_metrics)
|
|
||||||
metric_sources = {key: "remote_sensing" for key in soil_metrics}
|
|
||||||
for key, value in sensor_metrics.items():
|
|
||||||
resolved_metrics[key] = value
|
|
||||||
metric_sources[key] = sensor_metric_sources[key]
|
|
||||||
|
|
||||||
plant_assignments = get_farm_plant_assignments(farm)
|
plant_assignments = get_farm_plant_assignments(farm)
|
||||||
plant_snapshots = [assignment.plant for assignment in plant_assignments]
|
plant_snapshots = [assignment.plant for assignment in plant_assignments]
|
||||||
@@ -501,11 +528,7 @@ def get_farm_details(farm_uuid: str):
|
|||||||
"weather": WeatherForecastDetailSerializer(weather).data if weather else None,
|
"weather": WeatherForecastDetailSerializer(weather).data if weather else None,
|
||||||
"sensor_payload": farm.sensor_payload or {},
|
"sensor_payload": farm.sensor_payload or {},
|
||||||
"sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload),
|
"sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload),
|
||||||
"soil": {
|
"soil": soil_snapshot,
|
||||||
"resolved_metrics": resolved_metrics,
|
|
||||||
"metric_sources": metric_sources,
|
|
||||||
"satellite_snapshots": block_metric_snapshots,
|
|
||||||
},
|
|
||||||
"plant_ids": [plant.backend_plant_id for plant in plant_snapshots],
|
"plant_ids": [plant.backend_plant_id for plant in plant_snapshots],
|
||||||
"plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data,
|
"plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data,
|
||||||
"plant_assignments": [
|
"plant_assignments": [
|
||||||
@@ -528,9 +551,343 @@ def get_farm_details(farm_uuid: str):
|
|||||||
),
|
),
|
||||||
"created_at": farm.created_at,
|
"created_at": farm.created_at,
|
||||||
"updated_at": farm.updated_at,
|
"updated_at": farm.updated_at,
|
||||||
|
"source_metadata": {
|
||||||
|
"soil": soil_snapshot.get("source_metadata", {}),
|
||||||
|
"weather": {
|
||||||
|
"source": "center_location_forecast",
|
||||||
|
"scope": "location_center_based",
|
||||||
|
"location_id": center_location.id,
|
||||||
|
"note": "Weather remains tied to the farm center location.",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_farm_soil_snapshot(
|
||||||
|
center_location: SoilLocation,
|
||||||
|
*,
|
||||||
|
sensor_payload: dict | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
# Canonical farm soil metrics now come from farmer-level block aggregation.
|
||||||
|
aggregated_snapshot = build_farmer_block_aggregated_snapshot(
|
||||||
|
center_location,
|
||||||
|
sensor_payload=sensor_payload,
|
||||||
|
)
|
||||||
|
block_snapshots = build_location_block_satellite_snapshots(
|
||||||
|
center_location,
|
||||||
|
sensor_payload=sensor_payload,
|
||||||
|
)
|
||||||
|
if all(
|
||||||
|
snapshot.get("status") == "missing" and not snapshot.get("resolved_metrics")
|
||||||
|
for snapshot in block_snapshots
|
||||||
|
):
|
||||||
|
block_snapshots = []
|
||||||
|
|
||||||
|
has_explicit_blocks = bool((center_location.block_layout or {}).get("blocks"))
|
||||||
|
resolved_metrics = dict(aggregated_snapshot.get("resolved_metrics") or {})
|
||||||
|
metric_sources = dict(aggregated_snapshot.get("metric_sources") or {})
|
||||||
|
compatibility_sensor_overlay_applied = False
|
||||||
|
|
||||||
|
if not has_explicit_blocks:
|
||||||
|
compatibility_sensor_overlay_applied = _merge_legacy_sensor_metrics_if_missing(
|
||||||
|
resolved_metrics,
|
||||||
|
metric_sources,
|
||||||
|
sensor_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
cluster_breakdown = _build_cluster_breakdown(block_snapshots)
|
||||||
|
return {
|
||||||
|
"resolved_metrics": resolved_metrics,
|
||||||
|
"metric_sources": metric_sources,
|
||||||
|
"block_snapshots": block_snapshots,
|
||||||
|
"satellite_snapshots": block_snapshots,
|
||||||
|
"cluster_breakdown": cluster_breakdown,
|
||||||
|
"source_metadata": {
|
||||||
|
"canonical_source": "farmer_block_aggregated_snapshot",
|
||||||
|
"aggregation_strategy": aggregated_snapshot.get("aggregation_strategy") or "missing",
|
||||||
|
"status": aggregated_snapshot.get("status") or "missing",
|
||||||
|
"block_count": int(aggregated_snapshot.get("block_count") or len(block_snapshots)),
|
||||||
|
"has_explicit_blocks": has_explicit_blocks,
|
||||||
|
"compatibility_sensor_overlay_applied": compatibility_sensor_overlay_applied,
|
||||||
|
"policy": {
|
||||||
|
"sensor": "cluster_mean -> block_mean -> farm_mean",
|
||||||
|
"satellite": "cluster_mean -> block_mean -> farm_mean",
|
||||||
|
"weather": "location_center_based",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_legacy_sensor_metrics_if_missing(
|
||||||
|
resolved_metrics: dict,
|
||||||
|
metric_sources: dict,
|
||||||
|
sensor_payload: dict | None,
|
||||||
|
) -> bool:
|
||||||
|
sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(sensor_payload)
|
||||||
|
applied = False
|
||||||
|
for metric_name, metric_value in sensor_metrics.items():
|
||||||
|
if metric_name in resolved_metrics:
|
||||||
|
continue
|
||||||
|
resolved_metrics[metric_name] = metric_value
|
||||||
|
metric_sources[metric_name] = sensor_metric_sources[metric_name]
|
||||||
|
applied = True
|
||||||
|
return applied
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cluster_breakdown(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||||
|
cluster_breakdown: list[dict[str, object]] = []
|
||||||
|
for snapshot in block_snapshots:
|
||||||
|
block_code = str(snapshot.get("block_code") or "").strip()
|
||||||
|
for sub_block in snapshot.get("satellite_sub_blocks") or []:
|
||||||
|
cluster_breakdown.append(
|
||||||
|
{
|
||||||
|
"block_code": block_code,
|
||||||
|
"source": "satellite",
|
||||||
|
**dict(sub_block),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for sub_block in snapshot.get("sensor_sub_blocks") or []:
|
||||||
|
cluster_breakdown.append(
|
||||||
|
{
|
||||||
|
"block_code": block_code,
|
||||||
|
"source": "sensor",
|
||||||
|
**dict(sub_block),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return cluster_breakdown
|
||||||
|
|
||||||
|
|
||||||
|
AI_FARM_AGGREGATION_POLICY = {
|
||||||
|
"sensor": "cluster_mean_then_block_mean_then_farm_mean",
|
||||||
|
"satellite": "cluster_mean_then_block_mean_then_farm_mean",
|
||||||
|
"weather": "center_location_latest_forecast",
|
||||||
|
"default_block_policy": "1_main_block + 1_default_sub_block_when_missing",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_ai_farm_snapshot(farm_uuid: str) -> dict[str, object] | None:
|
||||||
|
farm = get_canonical_farm_record(farm_uuid)
|
||||||
|
if farm is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sync_sensor_parameters_from_payload(farm.sensor_payload)
|
||||||
|
|
||||||
|
center_location = farm.center_location
|
||||||
|
weather = farm.weather_forecast
|
||||||
|
if weather is None:
|
||||||
|
weather = center_location.weather_forecasts.order_by("-forecast_date", "-id").first()
|
||||||
|
|
||||||
|
soil_snapshot = _build_farm_soil_snapshot(
|
||||||
|
center_location,
|
||||||
|
sensor_payload=farm.sensor_payload,
|
||||||
|
)
|
||||||
|
block_metrics = _build_ai_block_metrics(soil_snapshot.get("block_snapshots") or [])
|
||||||
|
sub_block_metrics = _build_ai_sub_block_metrics(soil_snapshot.get("block_snapshots") or [])
|
||||||
|
plant_assignments = get_farm_plant_assignments(farm)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"farm_uuid": str(farm.farm_uuid),
|
||||||
|
"aggregation_policy": dict(AI_FARM_AGGREGATION_POLICY),
|
||||||
|
"farm_metrics": {
|
||||||
|
"resolved_metrics": dict(soil_snapshot.get("resolved_metrics") or {}),
|
||||||
|
"metric_sources": dict(soil_snapshot.get("metric_sources") or {}),
|
||||||
|
"status": (soil_snapshot.get("source_metadata") or {}).get("status", "missing"),
|
||||||
|
"aggregation_strategy": (soil_snapshot.get("source_metadata") or {}).get(
|
||||||
|
"aggregation_strategy", "missing"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"block_metrics": block_metrics,
|
||||||
|
"sub_block_metrics": sub_block_metrics,
|
||||||
|
"weather": {
|
||||||
|
"forecast": WeatherForecastDetailSerializer(weather).data if weather else None,
|
||||||
|
"source_metadata": {
|
||||||
|
"source": "center_location_forecast",
|
||||||
|
"scope": "location_center_based",
|
||||||
|
"location_id": center_location.id,
|
||||||
|
"status": "completed" if weather else "missing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"plants": [
|
||||||
|
{
|
||||||
|
"plant_id": assignment.plant.backend_plant_id,
|
||||||
|
"position": assignment.position,
|
||||||
|
"stage": assignment.stage,
|
||||||
|
"metadata": assignment.metadata,
|
||||||
|
"assigned_at": assignment.assigned_at,
|
||||||
|
"updated_at": assignment.updated_at,
|
||||||
|
"plant": PlantCatalogSnapshotSerializer(assignment.plant).data,
|
||||||
|
}
|
||||||
|
for assignment in plant_assignments
|
||||||
|
],
|
||||||
|
"irrigation_method": {
|
||||||
|
"id": farm.irrigation_method_id,
|
||||||
|
"details": (
|
||||||
|
IrrigationMethodSerializer(farm.irrigation_method).data
|
||||||
|
if farm.irrigation_method
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"source_metadata": {
|
||||||
|
"source": "farm_record",
|
||||||
|
"status": "completed" if farm.irrigation_method_id else "missing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"source_metadata": {
|
||||||
|
"farm": {
|
||||||
|
"farm_uuid": str(farm.farm_uuid),
|
||||||
|
"center_location_id": center_location.id,
|
||||||
|
"has_explicit_blocks": bool((center_location.block_layout or {}).get("blocks")),
|
||||||
|
},
|
||||||
|
"farm_metrics": dict(soil_snapshot.get("source_metadata") or {}),
|
||||||
|
"block_metrics": {
|
||||||
|
"source": "build_location_block_satellite_snapshots",
|
||||||
|
"block_count": len(block_metrics),
|
||||||
|
"status": "completed" if block_metrics else "missing",
|
||||||
|
},
|
||||||
|
"sub_block_metrics": {
|
||||||
|
"source": "block_snapshot_sub_blocks",
|
||||||
|
"sub_block_count": len(sub_block_metrics),
|
||||||
|
"status": "completed" if sub_block_metrics else "missing",
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"source": "center_location_forecast",
|
||||||
|
"scope": "location_center_based",
|
||||||
|
"location_id": center_location.id,
|
||||||
|
"note": "Weather remains tied to the farm center location.",
|
||||||
|
},
|
||||||
|
"plants": {
|
||||||
|
"source": "farm_plant_assignments",
|
||||||
|
"count": len(plant_assignments),
|
||||||
|
},
|
||||||
|
"irrigation_method": {
|
||||||
|
"source": "farm_record",
|
||||||
|
"status": "completed" if farm.irrigation_method_id else "missing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_ai_farm_snapshot_or_details(farm_uuid: str) -> dict[str, object] | None:
|
||||||
|
"""Return the canonical AI snapshot, or fall back to farm details for older consumers."""
|
||||||
|
snapshot = build_ai_farm_snapshot(farm_uuid)
|
||||||
|
if snapshot is None:
|
||||||
|
return None
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def get_ai_snapshot_metric(snapshot: dict[str, object] | None, metric_name: str) -> object | None:
|
||||||
|
if not isinstance(snapshot, dict):
|
||||||
|
return None
|
||||||
|
farm_metrics = snapshot.get("farm_metrics") or {}
|
||||||
|
resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {}
|
||||||
|
if isinstance(resolved_metrics, dict):
|
||||||
|
return resolved_metrics.get(metric_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_ai_snapshot_weather(snapshot: dict[str, object] | None) -> dict[str, object]:
|
||||||
|
if not isinstance(snapshot, dict):
|
||||||
|
return {}
|
||||||
|
weather_section = snapshot.get("weather") or {}
|
||||||
|
forecast = weather_section.get("forecast") if isinstance(weather_section, dict) else None
|
||||||
|
return forecast if isinstance(forecast, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ai_block_metrics(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||||
|
block_metrics: list[dict[str, object]] = []
|
||||||
|
for snapshot in block_snapshots:
|
||||||
|
block_code = str(snapshot.get("block_code") or "").strip() or "default-block"
|
||||||
|
block_metrics.append(
|
||||||
|
{
|
||||||
|
"block_code": block_code,
|
||||||
|
"resolved_metrics": dict(snapshot.get("resolved_metrics") or {}),
|
||||||
|
"metric_sources": dict(snapshot.get("metric_sources") or {}),
|
||||||
|
"satellite_metrics": dict(snapshot.get("satellite_metrics") or {}),
|
||||||
|
"sensor_metrics": dict(snapshot.get("sensor_metrics") or {}),
|
||||||
|
"status": snapshot.get("status") or "missing",
|
||||||
|
"aggregation_strategy": snapshot.get("aggregation_strategy") or "missing",
|
||||||
|
"sub_block_count": int(snapshot.get("sub_block_count") or 0),
|
||||||
|
"temporal_extent": snapshot.get("temporal_extent"),
|
||||||
|
"source_metadata": {
|
||||||
|
"source": "build_location_block_satellite_snapshots",
|
||||||
|
"block_code": block_code,
|
||||||
|
"run_id": snapshot.get("run_id"),
|
||||||
|
"cell_count": snapshot.get("cell_count"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return block_metrics
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ai_sub_block_metrics(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||||
|
sub_block_metrics: list[dict[str, object]] = []
|
||||||
|
for snapshot in block_snapshots:
|
||||||
|
block_code = str(snapshot.get("block_code") or "").strip() or "default-block"
|
||||||
|
satellite_sub_blocks = snapshot.get("satellite_sub_blocks") or []
|
||||||
|
sensor_sub_blocks = snapshot.get("sensor_sub_blocks") or []
|
||||||
|
if not satellite_sub_blocks and not sensor_sub_blocks:
|
||||||
|
sub_block_metrics.append(
|
||||||
|
{
|
||||||
|
"block_code": block_code,
|
||||||
|
"sub_block_code": "default-sub-block",
|
||||||
|
"resolved_metrics": dict(snapshot.get("resolved_metrics") or {}),
|
||||||
|
"satellite_metrics": dict(snapshot.get("satellite_metrics") or {}),
|
||||||
|
"sensor_metrics": dict(snapshot.get("sensor_metrics") or {}),
|
||||||
|
"status": snapshot.get("status") or "missing",
|
||||||
|
"source_metadata": {
|
||||||
|
"source": "default_sub_block_compatibility",
|
||||||
|
"scope": "future_default_policy",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
sub_blocks_by_code: dict[str, dict[str, object]] = {}
|
||||||
|
for sub_block in satellite_sub_blocks:
|
||||||
|
sub_block_code = str(sub_block.get("sub_block_code") or sub_block.get("cluster_code") or "").strip() or "default-sub-block"
|
||||||
|
entry = sub_blocks_by_code.setdefault(
|
||||||
|
sub_block_code,
|
||||||
|
{
|
||||||
|
"block_code": block_code,
|
||||||
|
"sub_block_code": sub_block_code,
|
||||||
|
"resolved_metrics": {},
|
||||||
|
"satellite_metrics": {},
|
||||||
|
"sensor_metrics": {},
|
||||||
|
"status": snapshot.get("status") or "missing",
|
||||||
|
"source_metadata": {
|
||||||
|
"source": "block_snapshot_sub_blocks",
|
||||||
|
"satellite_present": False,
|
||||||
|
"sensor_present": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry["satellite_metrics"] = dict(sub_block.get("resolved_metrics") or {})
|
||||||
|
entry["resolved_metrics"].update(entry["satellite_metrics"])
|
||||||
|
entry["source_metadata"]["satellite_present"] = True
|
||||||
|
for sub_block in sensor_sub_blocks:
|
||||||
|
sub_block_code = str(sub_block.get("sub_block_code") or sub_block.get("cluster_code") or "").strip() or "default-sub-block"
|
||||||
|
entry = sub_blocks_by_code.setdefault(
|
||||||
|
sub_block_code,
|
||||||
|
{
|
||||||
|
"block_code": block_code,
|
||||||
|
"sub_block_code": sub_block_code,
|
||||||
|
"resolved_metrics": {},
|
||||||
|
"satellite_metrics": {},
|
||||||
|
"sensor_metrics": {},
|
||||||
|
"status": snapshot.get("status") or "missing",
|
||||||
|
"source_metadata": {
|
||||||
|
"source": "block_snapshot_sub_blocks",
|
||||||
|
"satellite_present": False,
|
||||||
|
"sensor_present": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry["sensor_metrics"] = dict(sub_block.get("resolved_metrics") or {})
|
||||||
|
entry["resolved_metrics"].update(entry["sensor_metrics"])
|
||||||
|
entry["source_metadata"]["sensor_present"] = True
|
||||||
|
sub_block_metrics.extend(sub_blocks_by_code.values())
|
||||||
|
return sub_block_metrics
|
||||||
|
|
||||||
|
|
||||||
def resolve_center_location_from_boundary(
|
def resolve_center_location_from_boundary(
|
||||||
farm_boundary: dict | list,
|
farm_boundary: dict | list,
|
||||||
block_count: int = 1,
|
block_count: int = 1,
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ from location_data.models import (
|
|||||||
RemoteSensingSubdivisionResult,
|
RemoteSensingSubdivisionResult,
|
||||||
SoilLocation,
|
SoilLocation,
|
||||||
)
|
)
|
||||||
from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter
|
from farm_data.models import Device, PlantCatalogSnapshot, SensorData, SensorParameter
|
||||||
from farm_data.services import (
|
from farm_data.services import (
|
||||||
assign_farm_plants_from_backend_ids,
|
assign_farm_plants_from_backend_ids,
|
||||||
|
build_ai_farm_snapshot,
|
||||||
get_canonical_farm_record,
|
get_canonical_farm_record,
|
||||||
get_runtime_plant_for_farm,
|
get_runtime_plant_for_farm,
|
||||||
list_runtime_plants_for_farm,
|
list_runtime_plants_for_farm,
|
||||||
@@ -356,6 +357,309 @@ class FarmDetailApiTests(TestCase):
|
|||||||
0.6,
|
0.6,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_detail_uses_farmer_aggregated_snapshot_as_canonical_soil_source(self):
|
||||||
|
self.location.block_layout = {
|
||||||
|
"blocks": [
|
||||||
|
{"block_code": "block-a", "order": 1},
|
||||||
|
{"block_code": "block-b", "order": 2},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
self.location.save(update_fields=["block_layout", "updated_at"])
|
||||||
|
|
||||||
|
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||||
|
"farm_data.services.build_location_block_satellite_snapshots"
|
||||||
|
) as block_mock:
|
||||||
|
aggregated_mock.return_value = {
|
||||||
|
"status": "completed",
|
||||||
|
"aggregation_strategy": "farmer_block_mean",
|
||||||
|
"block_count": 2,
|
||||||
|
"resolved_metrics": {"nitrogen": 42.0, "ndvi": 0.61},
|
||||||
|
"metric_sources": {
|
||||||
|
"nitrogen": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 2},
|
||||||
|
"ndvi": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 2},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
block_mock.return_value = [
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"block_code": "block-a",
|
||||||
|
"resolved_metrics": {"nitrogen": 20.0, "ndvi": 0.5},
|
||||||
|
"metric_sources": {},
|
||||||
|
"satellite_sub_blocks": [{"sub_block_code": "cluster-a"}],
|
||||||
|
"sensor_sub_blocks": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"block_code": "block-b",
|
||||||
|
"resolved_metrics": {"nitrogen": 64.0, "ndvi": 0.72},
|
||||||
|
"metric_sources": {},
|
||||||
|
"satellite_sub_blocks": [{"sub_block_code": "cluster-b"}],
|
||||||
|
"sensor_sub_blocks": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()["data"]
|
||||||
|
self.assertEqual(payload["soil"]["resolved_metrics"]["nitrogen"], 42.0)
|
||||||
|
self.assertEqual(payload["soil"]["resolved_metrics"]["ndvi"], 0.61)
|
||||||
|
self.assertEqual(payload["soil"]["source_metadata"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||||
|
self.assertEqual(payload["soil"]["source_metadata"]["policy"]["sensor"], "cluster_mean -> block_mean -> farm_mean")
|
||||||
|
self.assertEqual(payload["source_metadata"]["weather"]["scope"], "location_center_based")
|
||||||
|
self.assertEqual(len(payload["soil"]["block_snapshots"]), 2)
|
||||||
|
self.assertEqual(len(payload["soil"]["cluster_breakdown"]), 2)
|
||||||
|
aggregated_mock.assert_called_once()
|
||||||
|
block_mock.assert_called_once()
|
||||||
|
|
||||||
|
def test_detail_without_explicit_blocks_keeps_aggregated_snapshot_and_marks_compatibility_policy(self):
|
||||||
|
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||||
|
"farm_data.services.build_location_block_satellite_snapshots"
|
||||||
|
) as block_mock:
|
||||||
|
aggregated_mock.return_value = {
|
||||||
|
"status": "completed",
|
||||||
|
"aggregation_strategy": "farmer_block_mean",
|
||||||
|
"block_count": 1,
|
||||||
|
"resolved_metrics": {"ndvi": 0.55},
|
||||||
|
"metric_sources": {
|
||||||
|
"ndvi": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
block_mock.return_value = [
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"block_code": "",
|
||||||
|
"resolved_metrics": {"ndvi": 0.55},
|
||||||
|
"metric_sources": {},
|
||||||
|
"satellite_sub_blocks": [],
|
||||||
|
"sensor_sub_blocks": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()["data"]
|
||||||
|
self.assertEqual(payload["soil"]["resolved_metrics"]["ndvi"], 0.55)
|
||||||
|
self.assertEqual(payload["soil"]["resolved_metrics"]["nitrogen"], 99.0)
|
||||||
|
self.assertFalse(payload["soil"]["source_metadata"]["has_explicit_blocks"])
|
||||||
|
self.assertTrue(payload["soil"]["source_metadata"]["compatibility_sensor_overlay_applied"])
|
||||||
|
self.assertEqual(payload["soil"]["metric_sources"]["nitrogen"]["type"], "sensor")
|
||||||
|
|
||||||
|
def test_detail_canonical_soil_metrics_do_not_come_from_single_raw_location_snapshot(self):
|
||||||
|
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||||
|
"farm_data.services.build_location_block_satellite_snapshots"
|
||||||
|
) as block_mock:
|
||||||
|
aggregated_mock.return_value = {
|
||||||
|
"status": "completed",
|
||||||
|
"aggregation_strategy": "farmer_block_mean",
|
||||||
|
"block_count": 1,
|
||||||
|
"resolved_metrics": {"soil_moisture": 12.0},
|
||||||
|
"metric_sources": {
|
||||||
|
"soil_moisture": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
block_mock.return_value = [
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"block_code": "block-1",
|
||||||
|
"resolved_metrics": {"soil_moisture": 77.0},
|
||||||
|
"metric_sources": {},
|
||||||
|
"satellite_sub_blocks": [],
|
||||||
|
"sensor_sub_blocks": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()["data"]
|
||||||
|
self.assertEqual(payload["soil"]["resolved_metrics"]["soil_moisture"], 12.0)
|
||||||
|
self.assertNotEqual(
|
||||||
|
payload["soil"]["resolved_metrics"]["soil_moisture"],
|
||||||
|
payload["soil"]["block_snapshots"][0]["resolved_metrics"]["soil_moisture"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildAiFarmSnapshotTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.location = SoilLocation.objects.create(
|
||||||
|
latitude="35.700000",
|
||||||
|
longitude="51.400000",
|
||||||
|
farm_boundary={"type": "Polygon", "coordinates": []},
|
||||||
|
)
|
||||||
|
self.weather = WeatherForecast.objects.create(
|
||||||
|
location=self.location,
|
||||||
|
forecast_date=date(2026, 4, 10),
|
||||||
|
temperature_min=12.0,
|
||||||
|
temperature_max=23.0,
|
||||||
|
temperature_mean=18.0,
|
||||||
|
precipitation=1.2,
|
||||||
|
humidity_mean=52.0,
|
||||||
|
)
|
||||||
|
self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=201, name="ذرت")
|
||||||
|
self.irrigation_method = IrrigationMethod.objects.create(name="تیپ")
|
||||||
|
self.farm_uuid = uuid.uuid4()
|
||||||
|
self.farm = SensorData.objects.create(
|
||||||
|
farm_uuid=self.farm_uuid,
|
||||||
|
center_location=self.location,
|
||||||
|
weather_forecast=self.weather,
|
||||||
|
irrigation_method=self.irrigation_method,
|
||||||
|
sensor_payload={"sensor-1": {"soil_moisture": 30.0}},
|
||||||
|
)
|
||||||
|
assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id])
|
||||||
|
|
||||||
|
def test_build_ai_farm_snapshot_returns_normalized_block_and_sub_block_metrics(self):
|
||||||
|
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||||
|
"farm_data.services.build_location_block_satellite_snapshots"
|
||||||
|
) as block_mock:
|
||||||
|
aggregated_mock.return_value = {
|
||||||
|
"status": "completed",
|
||||||
|
"aggregation_strategy": "farmer_block_mean",
|
||||||
|
"block_count": 1,
|
||||||
|
"resolved_metrics": {"ndvi": 0.6, "soil_moisture": 24.0},
|
||||||
|
"metric_sources": {"ndvi": {"type": "farmer_block"}, "soil_moisture": {"type": "farmer_block"}},
|
||||||
|
}
|
||||||
|
block_mock.return_value = [
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"block_code": "block-1",
|
||||||
|
"aggregation_strategy": "sub_block_mean",
|
||||||
|
"resolved_metrics": {"ndvi": 0.6, "soil_moisture": 24.0},
|
||||||
|
"metric_sources": {"ndvi": {"type": "satellite"}, "soil_moisture": {"type": "sensor"}},
|
||||||
|
"satellite_metrics": {"ndvi": 0.6},
|
||||||
|
"sensor_metrics": {"soil_moisture": 24.0},
|
||||||
|
"sub_block_count": 2,
|
||||||
|
"run_id": 91,
|
||||||
|
"cell_count": 8,
|
||||||
|
"temporal_extent": {"start_date": "2026-04-01", "end_date": "2026-04-30"},
|
||||||
|
"satellite_sub_blocks": [
|
||||||
|
{"sub_block_code": "cluster-a", "resolved_metrics": {"ndvi": 0.5}},
|
||||||
|
{"sub_block_code": "cluster-b", "resolved_metrics": {"ndvi": 0.7}},
|
||||||
|
],
|
||||||
|
"sensor_sub_blocks": [
|
||||||
|
{"sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 20.0}},
|
||||||
|
{"sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 28.0}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
|
||||||
|
|
||||||
|
self.assertIsNotNone(snapshot)
|
||||||
|
self.assertEqual(snapshot["farm_uuid"], str(self.farm_uuid))
|
||||||
|
self.assertEqual(snapshot["aggregation_policy"]["sensor"], "cluster_mean_then_block_mean_then_farm_mean")
|
||||||
|
self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"]["soil_moisture"], 24.0)
|
||||||
|
self.assertEqual(snapshot["block_metrics"][0]["block_code"], "block-1")
|
||||||
|
self.assertEqual(snapshot["block_metrics"][0]["source_metadata"]["run_id"], 91)
|
||||||
|
self.assertEqual(len(snapshot["sub_block_metrics"]), 2)
|
||||||
|
self.assertEqual(snapshot["sub_block_metrics"][0]["source_metadata"]["satellite_present"], True)
|
||||||
|
self.assertEqual(snapshot["sub_block_metrics"][0]["source_metadata"]["sensor_present"], True)
|
||||||
|
self.assertEqual(snapshot["weather"]["source_metadata"]["scope"], "location_center_based")
|
||||||
|
self.assertEqual(len(snapshot["plants"]), 1)
|
||||||
|
self.assertEqual(snapshot["irrigation_method"]["details"]["name"], self.irrigation_method.name)
|
||||||
|
|
||||||
|
def test_build_ai_farm_snapshot_without_explicit_subdivisions_uses_default_compatibility_shape(self):
|
||||||
|
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||||
|
"farm_data.services.build_location_block_satellite_snapshots"
|
||||||
|
) as block_mock:
|
||||||
|
aggregated_mock.return_value = {
|
||||||
|
"status": "completed",
|
||||||
|
"aggregation_strategy": "farmer_block_mean",
|
||||||
|
"block_count": 1,
|
||||||
|
"resolved_metrics": {"ndvi": 0.55},
|
||||||
|
"metric_sources": {"ndvi": {"type": "farmer_block"}},
|
||||||
|
}
|
||||||
|
block_mock.return_value = [
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"block_code": "",
|
||||||
|
"resolved_metrics": {"ndvi": 0.55},
|
||||||
|
"metric_sources": {"ndvi": {"type": "satellite"}},
|
||||||
|
"satellite_metrics": {"ndvi": 0.55},
|
||||||
|
"sensor_metrics": {},
|
||||||
|
"satellite_sub_blocks": [],
|
||||||
|
"sensor_sub_blocks": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
|
||||||
|
|
||||||
|
self.assertEqual(snapshot["block_metrics"][0]["block_code"], "default-block")
|
||||||
|
self.assertEqual(snapshot["sub_block_metrics"][0]["sub_block_code"], "default-sub-block")
|
||||||
|
self.assertEqual(
|
||||||
|
snapshot["sub_block_metrics"][0]["source_metadata"]["source"],
|
||||||
|
"default_sub_block_compatibility",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
snapshot["aggregation_policy"]["default_block_policy"],
|
||||||
|
"1_main_block + 1_default_sub_block_when_missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_build_ai_farm_snapshot_handles_missing_sensor_data(self):
|
||||||
|
self.farm.sensor_payload = None
|
||||||
|
self.farm.save(update_fields=["sensor_payload"])
|
||||||
|
|
||||||
|
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||||
|
"farm_data.services.build_location_block_satellite_snapshots"
|
||||||
|
) as block_mock:
|
||||||
|
aggregated_mock.return_value = {
|
||||||
|
"status": "completed",
|
||||||
|
"aggregation_strategy": "farmer_block_mean",
|
||||||
|
"block_count": 1,
|
||||||
|
"resolved_metrics": {"ndvi": 0.49},
|
||||||
|
"metric_sources": {"ndvi": {"type": "farmer_block"}},
|
||||||
|
}
|
||||||
|
block_mock.return_value = [
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"block_code": "block-1",
|
||||||
|
"resolved_metrics": {"ndvi": 0.49},
|
||||||
|
"metric_sources": {"ndvi": {"type": "satellite"}},
|
||||||
|
"satellite_metrics": {"ndvi": 0.49},
|
||||||
|
"sensor_metrics": {},
|
||||||
|
"satellite_sub_blocks": [],
|
||||||
|
"sensor_sub_blocks": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
|
||||||
|
|
||||||
|
self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"], {"ndvi": 0.49})
|
||||||
|
self.assertEqual(snapshot["block_metrics"][0]["sensor_metrics"], {})
|
||||||
|
|
||||||
|
def test_build_ai_farm_snapshot_handles_missing_remote_sensing_data(self):
|
||||||
|
with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch(
|
||||||
|
"farm_data.services.build_location_block_satellite_snapshots"
|
||||||
|
) as block_mock:
|
||||||
|
aggregated_mock.return_value = {
|
||||||
|
"status": "completed",
|
||||||
|
"aggregation_strategy": "farmer_block_mean",
|
||||||
|
"block_count": 1,
|
||||||
|
"resolved_metrics": {"soil_moisture": 30.0},
|
||||||
|
"metric_sources": {"soil_moisture": {"type": "farmer_block"}},
|
||||||
|
}
|
||||||
|
block_mock.return_value = [
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"block_code": "block-1",
|
||||||
|
"resolved_metrics": {"soil_moisture": 30.0},
|
||||||
|
"metric_sources": {"soil_moisture": {"type": "sensor"}},
|
||||||
|
"satellite_metrics": {},
|
||||||
|
"sensor_metrics": {"soil_moisture": 30.0},
|
||||||
|
"satellite_sub_blocks": [],
|
||||||
|
"sensor_sub_blocks": [
|
||||||
|
{"sub_block_code": "cluster-1", "resolved_metrics": {"soil_moisture": 30.0}}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
snapshot = build_ai_farm_snapshot(str(self.farm_uuid))
|
||||||
|
|
||||||
|
self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"]["soil_moisture"], 30.0)
|
||||||
|
self.assertEqual(snapshot["block_metrics"][0]["satellite_metrics"], {})
|
||||||
|
self.assertEqual(snapshot["sub_block_metrics"][0]["sensor_metrics"]["soil_moisture"], 30.0)
|
||||||
|
|
||||||
|
|
||||||
class FarmDataUpsertApiTests(TestCase):
|
class FarmDataUpsertApiTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -406,6 +710,10 @@ class FarmDataUpsertApiTests(TestCase):
|
|||||||
farm.sensor_payload["sensor-7-1"]["soil_moisture"],
|
farm.sensor_payload["sensor-7-1"]["soil_moisture"],
|
||||||
31.2,
|
31.2,
|
||||||
)
|
)
|
||||||
|
device = Device.objects.get(farm=farm, sensor_name="sensor-7-1")
|
||||||
|
self.assertEqual(device.location_id, self.location.id)
|
||||||
|
self.assertEqual(device.payload["soil_moisture"], 31.2)
|
||||||
|
self.assertIsNone(device.cluster_uuid)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists()
|
SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists()
|
||||||
)
|
)
|
||||||
@@ -444,6 +752,57 @@ class FarmDataUpsertApiTests(TestCase):
|
|||||||
["disease_pressure_index", "leaf_temperature", "leaf_wetness"],
|
["disease_pressure_index", "leaf_temperature", "leaf_wetness"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_post_syncs_device_rows_from_sensor_payload(self):
|
||||||
|
farm_uuid = uuid.uuid4()
|
||||||
|
cluster_uuid = uuid.uuid4()
|
||||||
|
|
||||||
|
create_response = self.client.post(
|
||||||
|
"/api/farm-data/",
|
||||||
|
data={
|
||||||
|
"farm_uuid": str(farm_uuid),
|
||||||
|
"farm_boundary": self.boundary,
|
||||||
|
"sensor_payload": {
|
||||||
|
"sensor-7-1": {
|
||||||
|
"cluster_uuid": str(cluster_uuid),
|
||||||
|
"soil_moisture": 31.2,
|
||||||
|
},
|
||||||
|
"leaf-sensor": {
|
||||||
|
"leaf_wetness": 10.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(create_response.status_code, 201)
|
||||||
|
farm = SensorData.objects.get(farm_uuid=farm_uuid)
|
||||||
|
self.assertEqual(Device.objects.filter(farm=farm).count(), 2)
|
||||||
|
soil_device = Device.objects.get(farm=farm, sensor_name="sensor-7-1")
|
||||||
|
self.assertEqual(str(soil_device.cluster_uuid), str(cluster_uuid))
|
||||||
|
self.assertEqual(soil_device.payload["soil_moisture"], 31.2)
|
||||||
|
|
||||||
|
update_response = self.client.post(
|
||||||
|
"/api/farm-data/",
|
||||||
|
data={
|
||||||
|
"farm_uuid": str(farm_uuid),
|
||||||
|
"farm_boundary": self.boundary,
|
||||||
|
"sensor_payload": {
|
||||||
|
"sensor-7-1": {
|
||||||
|
"cluster_uuid": str(cluster_uuid),
|
||||||
|
"soil_moisture": 33.8,
|
||||||
|
"nitrogen": 20.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(update_response.status_code, 200)
|
||||||
|
soil_device.refresh_from_db()
|
||||||
|
self.assertEqual(soil_device.payload["soil_moisture"], 33.8)
|
||||||
|
self.assertEqual(soil_device.payload["nitrogen"], 20.5)
|
||||||
|
self.assertEqual(Device.objects.filter(farm=farm, sensor_name="leaf-sensor").count(), 1)
|
||||||
|
|
||||||
def test_post_requires_farm_uuid_in_request_body(self):
|
def test_post_requires_farm_uuid_in_request_body(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/farm-data/",
|
"/api/farm-data/",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from .services import (
|
|||||||
ensure_location_and_weather_data,
|
ensure_location_and_weather_data,
|
||||||
get_farm_details,
|
get_farm_details,
|
||||||
resolve_center_location_from_boundary,
|
resolve_center_location_from_boundary,
|
||||||
|
sync_devices_from_sensor_data,
|
||||||
sync_sensor_parameters_from_payload,
|
sync_sensor_parameters_from_payload,
|
||||||
sync_plant_catalog_from_backend,
|
sync_plant_catalog_from_backend,
|
||||||
)
|
)
|
||||||
@@ -239,6 +240,8 @@ class FarmDataUpsertView(APIView):
|
|||||||
else:
|
else:
|
||||||
farm_data.save()
|
farm_data.save()
|
||||||
|
|
||||||
|
sync_devices_from_sensor_data(farm_data)
|
||||||
|
|
||||||
if plant_ids is not None:
|
if plant_ids is not None:
|
||||||
try:
|
try:
|
||||||
assign_farm_plants_from_backend_ids(farm_data, plant_ids)
|
assign_farm_plants_from_backend_ids(farm_data, plant_ids)
|
||||||
@@ -280,6 +283,13 @@ class FarmDetailView(APIView):
|
|||||||
"برای resolved_metrics، دادههای sensor روی دادههای خاک اولویت دارند "
|
"برای resolved_metrics، دادههای sensor روی دادههای خاک اولویت دارند "
|
||||||
"و در حالت چند سنسوره، مقادیر متعارض بهصورت deterministic تجمیع میشوند."
|
"و در حالت چند سنسوره، مقادیر متعارض بهصورت deterministic تجمیع میشوند."
|
||||||
),
|
),
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
"نمونه مسیر farm detail",
|
||||||
|
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
|
||||||
|
parameter_only=("farm_uuid", "path"),
|
||||||
|
),
|
||||||
|
],
|
||||||
responses={
|
responses={
|
||||||
200: build_response(
|
200: build_response(
|
||||||
FarmDetailEnvelopeSerializer,
|
FarmDetailEnvelopeSerializer,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def create_or_get_block_subdivision(
|
|||||||
اگر subdivision این بلوک قبلاً ساخته شده باشد همان را برمیگرداند؛
|
اگر subdivision این بلوک قبلاً ساخته شده باشد همان را برمیگرداند؛
|
||||||
در غیر این صورت الگوریتم grid + KMeans را اجرا و ذخیره میکند.
|
در غیر این صورت الگوریتم grid + KMeans را اجرا و ذخیره میکند.
|
||||||
"""
|
"""
|
||||||
from .models import BlockSubdivision
|
from .models import BlockSubdivision, build_default_sub_block, ensure_block_layout_defaults
|
||||||
|
|
||||||
existing = BlockSubdivision.objects.filter(
|
existing = BlockSubdivision.objects.filter(
|
||||||
soil_location=location,
|
soil_location=location,
|
||||||
@@ -244,7 +244,7 @@ def render_elbow_plot(
|
|||||||
|
|
||||||
|
|
||||||
def sync_block_layout_with_subdivision(location, subdivision) -> None:
|
def sync_block_layout_with_subdivision(location, subdivision) -> None:
|
||||||
layout = location.block_layout or {}
|
layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
|
||||||
blocks = list(layout.get("blocks") or [])
|
blocks = list(layout.get("blocks") or [])
|
||||||
target_block = None
|
target_block = None
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
@@ -263,7 +263,12 @@ def sync_block_layout_with_subdivision(location, subdivision) -> None:
|
|||||||
blocks.append(target_block)
|
blocks.append(target_block)
|
||||||
|
|
||||||
target_block["needs_subdivision"] = subdivision.centroid_count > 1
|
target_block["needs_subdivision"] = subdivision.centroid_count > 1
|
||||||
target_block["sub_blocks"] = list(subdivision.centroid_points or [])
|
target_block["sub_blocks"] = list(subdivision.centroid_points or []) or [
|
||||||
|
build_default_sub_block(
|
||||||
|
str(target_block.get("block_code") or "block-1"),
|
||||||
|
boundary=target_block.get("boundary") or {},
|
||||||
|
)
|
||||||
|
]
|
||||||
target_block["subdivision_summary"] = {
|
target_block["subdivision_summary"] = {
|
||||||
"chunk_size_sqm": subdivision.chunk_size_sqm,
|
"chunk_size_sqm": subdivision.chunk_size_sqm,
|
||||||
"grid_point_count": subdivision.grid_point_count,
|
"grid_point_count": subdivision.grid_point_count,
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.db.models import Avg
|
||||||
|
|
||||||
|
from crop_simulation.growth_simulation import GrowthSimulationContext, _run_projection_engine
|
||||||
|
from crop_simulation.services import PcseSimulationManager, build_simulation_payload_from_farm
|
||||||
|
from farm_data.services import get_canonical_farm_record, get_farm_plant_assignments
|
||||||
|
from .models import AnalysisGridObservation, RemoteSensingClusterBlock
|
||||||
|
from .satellite_snapshot import build_location_block_satellite_snapshots
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterRecommendationNotFound(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterRecommendationValidationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClusterPlantCandidate:
|
||||||
|
plant_id: int | None
|
||||||
|
plant_name: str
|
||||||
|
position: int | None
|
||||||
|
stage: str
|
||||||
|
score: float
|
||||||
|
predicted_yield: float | None
|
||||||
|
predicted_yield_tons: float | None
|
||||||
|
biomass: float | None
|
||||||
|
max_lai: float | None
|
||||||
|
simulation_engine: str | None
|
||||||
|
simulation_model_name: str | None
|
||||||
|
simulation_warning: str | None
|
||||||
|
supporting_metrics: dict[str, Any]
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"plant_id": self.plant_id,
|
||||||
|
"plant_name": self.plant_name,
|
||||||
|
"position": self.position,
|
||||||
|
"stage": self.stage,
|
||||||
|
"score": self.score,
|
||||||
|
"predicted_yield": self.predicted_yield,
|
||||||
|
"predicted_yield_tons": self.predicted_yield_tons,
|
||||||
|
"biomass": self.biomass,
|
||||||
|
"max_lai": self.max_lai,
|
||||||
|
"simulation_engine": self.simulation_engine,
|
||||||
|
"simulation_model_name": self.simulation_model_name,
|
||||||
|
"simulation_warning": self.simulation_warning,
|
||||||
|
"supporting_metrics": self.supporting_metrics,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(value: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp(value: float, minimum: float, maximum: float) -> float:
|
||||||
|
if minimum > maximum:
|
||||||
|
minimum, maximum = maximum, minimum
|
||||||
|
return max(minimum, min(value, maximum))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cluster_entries(
|
||||||
|
snapshots: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
cluster_blocks_by_uuid: dict[str, RemoteSensingClusterBlock],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
entries_by_key: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for snapshot in snapshots:
|
||||||
|
block_code = str(snapshot.get("block_code") or "").strip()
|
||||||
|
temporal_extent = snapshot.get("temporal_extent")
|
||||||
|
for satellite_sub_block in snapshot.get("satellite_sub_blocks") or []:
|
||||||
|
cluster_uuid = str(satellite_sub_block.get("cluster_uuid") or "").strip()
|
||||||
|
sub_block_code = str(satellite_sub_block.get("sub_block_code") or "").strip()
|
||||||
|
cluster_label = satellite_sub_block.get("cluster_label")
|
||||||
|
if cluster_uuid:
|
||||||
|
entry_key = cluster_uuid
|
||||||
|
elif sub_block_code:
|
||||||
|
entry_key = f"{block_code}::{sub_block_code}"
|
||||||
|
else:
|
||||||
|
entry_key = f"{block_code}::cluster-{cluster_label}"
|
||||||
|
entry = entries_by_key.setdefault(
|
||||||
|
entry_key,
|
||||||
|
{
|
||||||
|
"block_code": block_code,
|
||||||
|
"cluster_uuid": cluster_uuid or None,
|
||||||
|
"sub_block_code": sub_block_code,
|
||||||
|
"cluster_label": cluster_label,
|
||||||
|
"temporal_extent": temporal_extent,
|
||||||
|
"cluster_block": None,
|
||||||
|
"satellite_metrics": {},
|
||||||
|
"sensor_metrics": {},
|
||||||
|
"resolved_metrics": {},
|
||||||
|
"source_metadata": {
|
||||||
|
"block_status": snapshot.get("status") or "missing",
|
||||||
|
"aggregation_strategy": snapshot.get("aggregation_strategy") or "missing",
|
||||||
|
"has_satellite_metrics": False,
|
||||||
|
"has_sensor_metrics": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry["satellite_metrics"] = dict(satellite_sub_block.get("resolved_metrics") or {})
|
||||||
|
entry["resolved_metrics"].update(entry["satellite_metrics"])
|
||||||
|
entry["source_metadata"]["has_satellite_metrics"] = True
|
||||||
|
if cluster_uuid and cluster_uuid in cluster_blocks_by_uuid:
|
||||||
|
entry["cluster_block"] = cluster_blocks_by_uuid[cluster_uuid]
|
||||||
|
|
||||||
|
for sensor_sub_block in snapshot.get("sensor_sub_blocks") or []:
|
||||||
|
cluster_uuid = str(sensor_sub_block.get("cluster_uuid") or "").strip()
|
||||||
|
sub_block_code = str(sensor_sub_block.get("sub_block_code") or "").strip()
|
||||||
|
cluster_label = sensor_sub_block.get("cluster_label")
|
||||||
|
candidate_keys = [
|
||||||
|
cluster_uuid,
|
||||||
|
f"{block_code}::{sub_block_code}" if sub_block_code else "",
|
||||||
|
f"{block_code}::cluster-{cluster_label}" if cluster_label is not None else "",
|
||||||
|
]
|
||||||
|
entry = None
|
||||||
|
for candidate_key in candidate_keys:
|
||||||
|
if candidate_key and candidate_key in entries_by_key:
|
||||||
|
entry = entries_by_key[candidate_key]
|
||||||
|
break
|
||||||
|
if entry is None:
|
||||||
|
continue
|
||||||
|
entry["sensor_metrics"] = dict(sensor_sub_block.get("resolved_metrics") or {})
|
||||||
|
entry["resolved_metrics"].update(entry["sensor_metrics"])
|
||||||
|
entry["source_metadata"]["has_sensor_metrics"] = True
|
||||||
|
|
||||||
|
return list(entries_by_key.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_missing_satellite_metrics(cluster_entries: list[dict[str, Any]]) -> None:
|
||||||
|
for cluster_entry in cluster_entries:
|
||||||
|
cluster_block = cluster_entry.get("cluster_block")
|
||||||
|
if cluster_block is None:
|
||||||
|
continue
|
||||||
|
needs_soil_vv = "soil_vv" not in (cluster_entry.get("resolved_metrics") or {})
|
||||||
|
if not needs_soil_vv:
|
||||||
|
continue
|
||||||
|
observation_summary = AnalysisGridObservation.objects.filter(
|
||||||
|
cell__cell_code__in=list(cluster_block.cell_codes or []),
|
||||||
|
temporal_start=cluster_block.result.temporal_start,
|
||||||
|
temporal_end=cluster_block.result.temporal_end,
|
||||||
|
).aggregate(soil_vv_mean=Avg("soil_vv"))
|
||||||
|
soil_vv_mean = _safe_float(observation_summary.get("soil_vv_mean"))
|
||||||
|
if soil_vv_mean is None:
|
||||||
|
continue
|
||||||
|
rounded_soil_vv = round(soil_vv_mean, 6)
|
||||||
|
cluster_entry.setdefault("satellite_metrics", {})["soil_vv"] = rounded_soil_vv
|
||||||
|
cluster_entry.setdefault("resolved_metrics", {})["soil_vv"] = rounded_soil_vv
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cluster_overrides(
|
||||||
|
base_payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
cluster_metrics: dict[str, Any],
|
||||||
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
soil_parameters = deepcopy(base_payload.get("soil") or {})
|
||||||
|
site_parameters = deepcopy(base_payload.get("site_parameters") or {})
|
||||||
|
|
||||||
|
ndwi = _safe_float(cluster_metrics.get("ndwi"))
|
||||||
|
if ndwi is not None:
|
||||||
|
smfcf = _clamp(ndwi, 0.2, 0.55)
|
||||||
|
smw = _clamp(smfcf * 0.45, 0.05, max(smfcf - 0.02, 0.06))
|
||||||
|
sm0 = _clamp(
|
||||||
|
min(max(smfcf + 0.08, smw + 0.12), 0.6),
|
||||||
|
max(smfcf + 0.02, smw + 0.05),
|
||||||
|
0.8,
|
||||||
|
)
|
||||||
|
soil_parameters["SMFCF"] = round(smfcf, 3)
|
||||||
|
soil_parameters["SMW"] = round(smw, 3)
|
||||||
|
soil_parameters["SM0"] = round(sm0, 3)
|
||||||
|
site_parameters["SMLIM"] = round(_clamp(smfcf, smw, sm0), 3)
|
||||||
|
|
||||||
|
soil_moisture = _safe_float(cluster_metrics.get("soil_moisture"))
|
||||||
|
if soil_moisture is not None:
|
||||||
|
soil_parameters["soil_moisture"] = soil_moisture
|
||||||
|
site_parameters["WAV"] = round(max(soil_moisture, 0.0), 3)
|
||||||
|
|
||||||
|
nutrient_mappings = (
|
||||||
|
("nitrogen", "NAVAILI", "nitrogen"),
|
||||||
|
("phosphorus", "P_STATUS", "phosphorus"),
|
||||||
|
("potassium", "K_STATUS", "potassium"),
|
||||||
|
("soil_ph", "SOIL_PH", "soil_ph"),
|
||||||
|
("electrical_conductivity", "EC", "electrical_conductivity"),
|
||||||
|
)
|
||||||
|
for metric_name, site_key, soil_key in nutrient_mappings:
|
||||||
|
value = _safe_float(cluster_metrics.get(metric_name))
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
soil_parameters[soil_key] = value
|
||||||
|
site_parameters[site_key] = value
|
||||||
|
|
||||||
|
return soil_parameters, site_parameters
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_cluster_block(cluster_block: RemoteSensingClusterBlock | None) -> dict[str, Any] | None:
|
||||||
|
if cluster_block is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"uuid": str(cluster_block.uuid),
|
||||||
|
"sub_block_code": cluster_block.sub_block_code,
|
||||||
|
"cluster_label": cluster_block.cluster_label,
|
||||||
|
"chunk_size_sqm": cluster_block.chunk_size_sqm,
|
||||||
|
"centroid_lat": cluster_block.centroid_lat,
|
||||||
|
"centroid_lon": cluster_block.centroid_lon,
|
||||||
|
"center_cell_code": cluster_block.center_cell_code,
|
||||||
|
"center_cell_lat": cluster_block.center_cell_lat,
|
||||||
|
"center_cell_lon": cluster_block.center_cell_lon,
|
||||||
|
"cell_count": cluster_block.cell_count,
|
||||||
|
"cell_codes": list(cluster_block.cell_codes or []),
|
||||||
|
"geometry": cluster_block.geometry,
|
||||||
|
"metadata": dict(cluster_block.metadata or {}),
|
||||||
|
"created_at": cluster_block.created_at,
|
||||||
|
"updated_at": cluster_block.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _simulate_candidate(
|
||||||
|
*,
|
||||||
|
base_payload: dict[str, Any],
|
||||||
|
soil_parameters: dict[str, Any],
|
||||||
|
site_parameters: dict[str, Any],
|
||||||
|
) -> tuple[dict[str, Any], str | None]:
|
||||||
|
manager = PcseSimulationManager()
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
manager.run_simulation(
|
||||||
|
weather=base_payload.get("weather") or [],
|
||||||
|
soil=soil_parameters,
|
||||||
|
crop_parameters=base_payload.get("crop_parameters") or {},
|
||||||
|
agromanagement=base_payload.get("agromanagement") or [],
|
||||||
|
site_parameters=site_parameters,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
context = GrowthSimulationContext(
|
||||||
|
farm_uuid=None,
|
||||||
|
plant_name=str((base_payload.get("crop_parameters") or {}).get("crop_name") or ""),
|
||||||
|
plant=base_payload.get("plant"),
|
||||||
|
dynamic_parameters=[],
|
||||||
|
weather=base_payload.get("weather") or [],
|
||||||
|
crop_parameters=base_payload.get("crop_parameters") or {},
|
||||||
|
soil_parameters=soil_parameters,
|
||||||
|
site_parameters=site_parameters,
|
||||||
|
agromanagement=base_payload.get("agromanagement") or [],
|
||||||
|
page_size=10,
|
||||||
|
)
|
||||||
|
fallback_result = _run_projection_engine(context)
|
||||||
|
return fallback_result, f"simulation_fallback:{exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def _rank_cluster_plants(
|
||||||
|
cluster_entry: dict[str, Any],
|
||||||
|
*,
|
||||||
|
plant_assignments: list[Any],
|
||||||
|
base_payloads: dict[str, dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
candidates: list[ClusterPlantCandidate] = []
|
||||||
|
for assignment in plant_assignments:
|
||||||
|
plant_name = str(getattr(assignment.plant, "name", "") or "").strip()
|
||||||
|
if not plant_name:
|
||||||
|
continue
|
||||||
|
base_payload = base_payloads[plant_name]
|
||||||
|
soil_parameters, site_parameters = _build_cluster_overrides(
|
||||||
|
base_payload,
|
||||||
|
cluster_metrics=dict(cluster_entry.get("resolved_metrics") or {}),
|
||||||
|
)
|
||||||
|
simulation_result, simulation_warning = _simulate_candidate(
|
||||||
|
base_payload=base_payload,
|
||||||
|
soil_parameters=soil_parameters,
|
||||||
|
site_parameters=site_parameters,
|
||||||
|
)
|
||||||
|
metrics = dict(simulation_result.get("metrics") or {})
|
||||||
|
predicted_yield = _safe_float(metrics.get("yield_estimate"))
|
||||||
|
biomass = _safe_float(metrics.get("biomass"))
|
||||||
|
max_lai = _safe_float(metrics.get("max_lai"))
|
||||||
|
predicted_yield_tons = None if predicted_yield is None else round(max(predicted_yield, 0.0) / 1000.0, 4)
|
||||||
|
score = round(predicted_yield if predicted_yield is not None else -1.0, 4)
|
||||||
|
candidates.append(
|
||||||
|
ClusterPlantCandidate(
|
||||||
|
plant_id=getattr(assignment.plant, "backend_plant_id", None),
|
||||||
|
plant_name=plant_name,
|
||||||
|
position=getattr(assignment, "position", None),
|
||||||
|
stage=str(getattr(assignment, "stage", "") or ""),
|
||||||
|
score=score,
|
||||||
|
predicted_yield=round(predicted_yield, 4) if predicted_yield is not None else None,
|
||||||
|
predicted_yield_tons=predicted_yield_tons,
|
||||||
|
biomass=round(biomass, 4) if biomass is not None else None,
|
||||||
|
max_lai=round(max_lai, 4) if max_lai is not None else None,
|
||||||
|
simulation_engine=simulation_result.get("engine"),
|
||||||
|
simulation_model_name=simulation_result.get("model_name"),
|
||||||
|
simulation_warning=simulation_warning,
|
||||||
|
supporting_metrics=metrics,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ranked_candidates = sorted(
|
||||||
|
candidates,
|
||||||
|
key=lambda item: (
|
||||||
|
item.score,
|
||||||
|
item.biomass if item.biomass is not None else float("-inf"),
|
||||||
|
-1 * (item.position if item.position is not None else 10_000),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return [candidate.as_dict() for candidate in ranked_candidates]
|
||||||
|
|
||||||
|
|
||||||
|
def build_cluster_crop_recommendations(farm_uuid: str) -> dict[str, Any]:
|
||||||
|
farm = get_canonical_farm_record(farm_uuid)
|
||||||
|
if farm is None:
|
||||||
|
raise ClusterRecommendationNotFound("مزرعه پیدا نشد.")
|
||||||
|
|
||||||
|
plant_assignments = get_farm_plant_assignments(farm)
|
||||||
|
if not plant_assignments:
|
||||||
|
raise ClusterRecommendationValidationError("برای این مزرعه هنوز هیچ گیاهی در farm_data ثبت نشده است.")
|
||||||
|
|
||||||
|
location = farm.center_location
|
||||||
|
snapshots = build_location_block_satellite_snapshots(
|
||||||
|
location,
|
||||||
|
sensor_payload=farm.sensor_payload,
|
||||||
|
)
|
||||||
|
cluster_uuids = {
|
||||||
|
str(sub_block.get("cluster_uuid") or "").strip()
|
||||||
|
for snapshot in snapshots
|
||||||
|
for sub_block in (snapshot.get("satellite_sub_blocks") or [])
|
||||||
|
if str(sub_block.get("cluster_uuid") or "").strip()
|
||||||
|
}
|
||||||
|
if not cluster_uuids:
|
||||||
|
raise ClusterRecommendationNotFound("برای این مزرعه هنوز خروجی KMeans در location_data ثبت نشده است.")
|
||||||
|
|
||||||
|
cluster_blocks_by_uuid = {
|
||||||
|
str(cluster_block.uuid): cluster_block
|
||||||
|
for cluster_block in RemoteSensingClusterBlock.objects.filter(uuid__in=list(cluster_uuids)).select_related("result")
|
||||||
|
}
|
||||||
|
cluster_entries = _build_cluster_entries(
|
||||||
|
snapshots,
|
||||||
|
cluster_blocks_by_uuid=cluster_blocks_by_uuid,
|
||||||
|
)
|
||||||
|
_attach_missing_satellite_metrics(cluster_entries)
|
||||||
|
if not cluster_entries:
|
||||||
|
raise ClusterRecommendationNotFound("برای این مزرعه هنوز کلاستر قابل استفاده پیدا نشد.")
|
||||||
|
|
||||||
|
base_payloads: dict[str, dict[str, Any]] = {}
|
||||||
|
for assignment in plant_assignments:
|
||||||
|
plant_name = str(getattr(assignment.plant, "name", "") or "").strip()
|
||||||
|
if not plant_name or plant_name in base_payloads:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
base_payloads[plant_name] = build_simulation_payload_from_farm(
|
||||||
|
farm_uuid=str(farm.farm_uuid),
|
||||||
|
plant_name=plant_name,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ClusterRecommendationValidationError(
|
||||||
|
f"مقایسه گیاهها با crop_simulation انجام نشد: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
response_clusters: list[dict[str, Any]] = []
|
||||||
|
for cluster_entry in cluster_entries:
|
||||||
|
candidate_plants = _rank_cluster_plants(
|
||||||
|
cluster_entry,
|
||||||
|
plant_assignments=plant_assignments,
|
||||||
|
base_payloads=base_payloads,
|
||||||
|
)
|
||||||
|
response_clusters.append(
|
||||||
|
{
|
||||||
|
"block_code": cluster_entry.get("block_code") or "",
|
||||||
|
"cluster_uuid": cluster_entry.get("cluster_uuid"),
|
||||||
|
"sub_block_code": cluster_entry.get("sub_block_code") or "",
|
||||||
|
"cluster_label": cluster_entry.get("cluster_label"),
|
||||||
|
"temporal_extent": cluster_entry.get("temporal_extent"),
|
||||||
|
"cluster_block": _serialize_cluster_block(cluster_entry.get("cluster_block")),
|
||||||
|
"satellite_metrics": dict(cluster_entry.get("satellite_metrics") or {}),
|
||||||
|
"sensor_metrics": dict(cluster_entry.get("sensor_metrics") or {}),
|
||||||
|
"resolved_metrics": dict(cluster_entry.get("resolved_metrics") or {}),
|
||||||
|
"candidate_plants": candidate_plants,
|
||||||
|
"suggested_plant": candidate_plants[0] if candidate_plants else None,
|
||||||
|
"source_metadata": dict(cluster_entry.get("source_metadata") or {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"farm_uuid": str(farm.farm_uuid),
|
||||||
|
"location_id": location.id,
|
||||||
|
"evaluated_plant_count": len(base_payloads),
|
||||||
|
"cluster_count": len(response_clusters),
|
||||||
|
"registered_plants": [
|
||||||
|
{
|
||||||
|
"plant_id": assignment.plant.backend_plant_id,
|
||||||
|
"plant_name": assignment.plant.name,
|
||||||
|
"position": assignment.position,
|
||||||
|
"stage": assignment.stage,
|
||||||
|
}
|
||||||
|
for assignment in plant_assignments
|
||||||
|
],
|
||||||
|
"clusters": response_clusters,
|
||||||
|
"source_metadata": {
|
||||||
|
"source": "location_data+kmeans+farm_data+crop_simulation",
|
||||||
|
"location_id": location.id,
|
||||||
|
"snapshot_block_count": len(snapshots),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ from django.db import transaction
|
|||||||
|
|
||||||
from .block_subdivision import detect_elbow_point, point_in_polygon, render_elbow_plot
|
from .block_subdivision import detect_elbow_point, point_in_polygon, render_elbow_plot
|
||||||
from .models import (
|
from .models import (
|
||||||
|
build_default_sub_block,
|
||||||
|
ensure_block_layout_defaults,
|
||||||
AnalysisGridObservation,
|
AnalysisGridObservation,
|
||||||
BlockSubdivision,
|
BlockSubdivision,
|
||||||
RemoteSensingClusterBlock,
|
RemoteSensingClusterBlock,
|
||||||
@@ -1272,7 +1274,7 @@ def sync_location_block_layout_with_result(
|
|||||||
result: RemoteSensingSubdivisionResult,
|
result: RemoteSensingSubdivisionResult,
|
||||||
cluster_summaries: list[dict[str, Any]],
|
cluster_summaries: list[dict[str, Any]],
|
||||||
) -> None:
|
) -> None:
|
||||||
layout = dict(location.block_layout or {})
|
layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
|
||||||
blocks = list(layout.get("blocks") or [])
|
blocks = list(layout.get("blocks") or [])
|
||||||
target_block = None
|
target_block = None
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
@@ -1307,6 +1309,14 @@ def sync_location_block_layout_with_result(
|
|||||||
}
|
}
|
||||||
for cluster in cluster_summaries
|
for cluster in cluster_summaries
|
||||||
]
|
]
|
||||||
|
if not target_block["sub_blocks"]:
|
||||||
|
target_block["sub_blocks"] = [
|
||||||
|
build_default_sub_block(
|
||||||
|
str(target_block.get("block_code") or "block-1"),
|
||||||
|
boundary=target_block.get("boundary") or {},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
target_block["subdivision_summary"] = {
|
target_block["subdivision_summary"] = {
|
||||||
"type": "data_driven_remote_sensing",
|
"type": "data_driven_remote_sensing",
|
||||||
"cluster_count": result.cluster_count,
|
"cluster_count": result.cluster_count,
|
||||||
|
|||||||
@@ -0,0 +1,533 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
|
|
||||||
|
from location_data.models import (
|
||||||
|
AnalysisGridCell,
|
||||||
|
AnalysisGridObservation,
|
||||||
|
BlockSubdivision,
|
||||||
|
NdviObservation,
|
||||||
|
RemoteSensingClusterAssignment,
|
||||||
|
RemoteSensingClusterBlock,
|
||||||
|
RemoteSensingRun,
|
||||||
|
RemoteSensingSubdivisionOption,
|
||||||
|
RemoteSensingSubdivisionOptionAssignment,
|
||||||
|
RemoteSensingSubdivisionOptionBlock,
|
||||||
|
RemoteSensingSubdivisionResult,
|
||||||
|
SoilLocation,
|
||||||
|
)
|
||||||
|
|
||||||
|
SEED_DATA = {
|
||||||
|
"soillocations": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"latitude": "50.000000",
|
||||||
|
"longitude": "50.000000",
|
||||||
|
"task_id": "",
|
||||||
|
"farm_boundary": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[49.9995, 49.9995], [50.0005, 49.9995], [50.0005, 50.0005], [49.9995, 50.0005], [49.9995, 49.9995]]],
|
||||||
|
},
|
||||||
|
"input_block_count": 1,
|
||||||
|
"block_layout": {
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"source": "default",
|
||||||
|
"boundary": {},
|
||||||
|
"block_code": "block-1",
|
||||||
|
"sub_blocks": [],
|
||||||
|
"needs_subdivision": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"source": "remote_sensing",
|
||||||
|
"block_code": "",
|
||||||
|
"sub_blocks": [
|
||||||
|
{
|
||||||
|
"geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.9995], [49.99992, 49.9995], [50.000339, 49.9995], [50.000339, 49.99977], [50.000339, 50.00004], [49.99992, 50.00004], [49.9995, 50.00004], [49.9995, 49.99977], [49.9995, 49.9995]]]},
|
||||||
|
"metadata": {"source": "analysis_grid_cells", "center_selection": {"strategy": "coordinate_1_center", "center_radius": 0.0004993, "center_cell_code": "loc-1__block-farm__chunk-900__r0000c0000", "center_mean_distance": 0.00029732}, "cell_geometry_type": "Polygon"},
|
||||||
|
"cell_count": 4,
|
||||||
|
"centroid_lat": 49.99977,
|
||||||
|
"centroid_lon": 49.99992,
|
||||||
|
"cluster_uuid": "daa278cb-cf75-4f17-bc94-bb3a780dd4d4",
|
||||||
|
"cluster_label": 0,
|
||||||
|
"sub_block_code": "cluster-0",
|
||||||
|
"center_cell_lat": 49.999635,
|
||||||
|
"center_cell_lon": 49.99971,
|
||||||
|
"center_cell_code": "loc-1__block-farm__chunk-900__r0000c0000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.9995], [50.000759, 49.9995], [50.000759, 49.99977], [50.000759, 50.00004], [50.000759, 50.000309], [50.000759, 50.000579], [50.000339, 50.000579], [49.99992, 50.000579], [49.9995, 50.000579], [49.9995, 50.000309], [49.9995, 50.00004], [49.99992, 50.00004], [50.000339, 50.00004], [50.000339, 49.99977], [50.000339, 49.9995]]]},
|
||||||
|
"metadata": {"source": "analysis_grid_cells", "center_selection": {"strategy": "coordinate_1_center", "center_radius": 0.0006827, "center_cell_code": "loc-1__block-farm__chunk-900__r0002c0001", "center_mean_distance": 0.00041092}, "cell_geometry_type": "Polygon"},
|
||||||
|
"cell_count": 8,
|
||||||
|
"centroid_lat": 50.000174,
|
||||||
|
"centroid_lon": 50.000235,
|
||||||
|
"cluster_uuid": "e9beea1c-8736-4c45-ac5b-f186705bad76",
|
||||||
|
"cluster_label": 1,
|
||||||
|
"sub_block_code": "cluster-1",
|
||||||
|
"center_cell_lat": 50.000174,
|
||||||
|
"center_cell_lon": 50.00013,
|
||||||
|
"center_cell_code": "loc-1__block-farm__chunk-900__r0002c0001",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"needs_subdivision": True,
|
||||||
|
"subdivision_summary": {
|
||||||
|
"type": "data_driven_remote_sensing",
|
||||||
|
"run_id": 1,
|
||||||
|
"cluster_count": 2,
|
||||||
|
"used_cell_count": 12,
|
||||||
|
"selected_features": ["ndvi", "ndwi", "soil_vv_db"],
|
||||||
|
"skipped_cell_count": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"algorithm_status": "completed",
|
||||||
|
"default_full_farm": True,
|
||||||
|
"input_block_count": 1,
|
||||||
|
"analysis_grid_summary": {"cell_count": 12, "chunk_size_sqm": 900},
|
||||||
|
},
|
||||||
|
"created_at": "2026-05-11T14:41:43.319380+00:00",
|
||||||
|
"updated_at": "2026-05-12T12:37:38.239904+00:00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"blocksubdivisions": [],
|
||||||
|
"remotesensingruns": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"soil_location_id": 1,
|
||||||
|
"block_subdivision_id": None,
|
||||||
|
"block_code": "",
|
||||||
|
"provider": "openeo",
|
||||||
|
"chunk_size_sqm": 900,
|
||||||
|
"temporal_start": "2026-04-11",
|
||||||
|
"temporal_end": "2026-05-11",
|
||||||
|
"status": "success",
|
||||||
|
"metadata": {"farm_uuid": "11111111-1111-1111-1111-111111111111", "requested_via": "api", "scope": "all_blocks"},
|
||||||
|
"error_message": "",
|
||||||
|
"started_at": "2026-05-12T12:19:03.911826+00:00",
|
||||||
|
"finished_at": "2026-05-12T12:37:39.018428+00:00",
|
||||||
|
"created_at": "2026-05-12T12:19:03.912346+00:00",
|
||||||
|
"updated_at": "2026-05-12T12:37:39.019007+00:00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"remotesensingsubdivisionresults": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"soil_location_id": 1,
|
||||||
|
"run_id": 1,
|
||||||
|
"block_subdivision_id": None,
|
||||||
|
"block_code": "",
|
||||||
|
"chunk_size_sqm": 900,
|
||||||
|
"temporal_start": "2026-04-11",
|
||||||
|
"temporal_end": "2026-05-11",
|
||||||
|
"cluster_count": 2,
|
||||||
|
"selected_features": ["ndvi", "ndwi", "soil_vv_db"],
|
||||||
|
"skipped_cell_codes": [],
|
||||||
|
"metadata": {"selection_strategy": "elbow", "used_cell_count": 12},
|
||||||
|
"created_at": "2026-05-12T12:37:27.897155+00:00",
|
||||||
|
"updated_at": "2026-05-12T12:37:27.897180+00:00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"remotesensingclusterblocks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"uuid": "daa278cb-cf75-4f17-bc94-bb3a780dd4d4",
|
||||||
|
"result_id": 1,
|
||||||
|
"soil_location_id": 1,
|
||||||
|
"block_subdivision_id": None,
|
||||||
|
"block_code": "",
|
||||||
|
"sub_block_code": "cluster-0",
|
||||||
|
"cluster_label": 0,
|
||||||
|
"chunk_size_sqm": 900,
|
||||||
|
"centroid_lat": "49.999770",
|
||||||
|
"centroid_lon": "49.999920",
|
||||||
|
"center_cell_code": "loc-1__block-farm__chunk-900__r0000c0000",
|
||||||
|
"center_cell_lat": "49.999635",
|
||||||
|
"center_cell_lon": "49.999710",
|
||||||
|
"geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.9995], [49.99992, 49.9995], [50.000339, 49.9995], [50.000339, 49.99977], [50.000339, 50.00004], [49.99992, 50.00004], [49.9995, 50.00004], [49.9995, 49.99977], [49.9995, 49.9995]]]},
|
||||||
|
"cell_count": 4,
|
||||||
|
"cell_codes": ["loc-1__block-farm__chunk-900__r0000c0000", "loc-1__block-farm__chunk-900__r0000c0001", "loc-1__block-farm__chunk-900__r0001c0000", "loc-1__block-farm__chunk-900__r0001c0001"],
|
||||||
|
"metadata": {"source": "analysis_grid_cells"},
|
||||||
|
"created_at": "2026-05-12T12:37:27.899874+00:00",
|
||||||
|
"updated_at": "2026-05-12T12:37:27.899899+00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"uuid": "e9beea1c-8736-4c45-ac5b-f186705bad76",
|
||||||
|
"result_id": 1,
|
||||||
|
"soil_location_id": 1,
|
||||||
|
"block_subdivision_id": None,
|
||||||
|
"block_code": "",
|
||||||
|
"sub_block_code": "cluster-1",
|
||||||
|
"cluster_label": 1,
|
||||||
|
"chunk_size_sqm": 900,
|
||||||
|
"centroid_lat": "50.000174",
|
||||||
|
"centroid_lon": "50.000235",
|
||||||
|
"center_cell_code": "loc-1__block-farm__chunk-900__r0002c0001",
|
||||||
|
"center_cell_lat": "50.000174",
|
||||||
|
"center_cell_lon": "50.000130",
|
||||||
|
"geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.9995], [50.000759, 49.9995], [50.000759, 49.99977], [50.000759, 50.00004], [50.000759, 50.000309], [50.000759, 50.000579], [50.000339, 50.000579], [49.99992, 50.000579], [49.9995, 50.000579], [49.9995, 50.000309], [49.9995, 50.00004], [49.99992, 50.00004], [50.000339, 50.00004], [50.000339, 49.99977], [50.000339, 49.9995]]]},
|
||||||
|
"cell_count": 8,
|
||||||
|
"cell_codes": ["loc-1__block-farm__chunk-900__r0000c0002", "loc-1__block-farm__chunk-900__r0001c0002", "loc-1__block-farm__chunk-900__r0002c0000", "loc-1__block-farm__chunk-900__r0002c0001", "loc-1__block-farm__chunk-900__r0002c0002", "loc-1__block-farm__chunk-900__r0003c0000", "loc-1__block-farm__chunk-900__r0003c0001", "loc-1__block-farm__chunk-900__r0003c0002"],
|
||||||
|
"metadata": {"source": "analysis_grid_cells"},
|
||||||
|
"created_at": "2026-05-12T12:37:27.901926+00:00",
|
||||||
|
"updated_at": "2026-05-12T12:37:27.901945+00:00",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"analysisgridcells": [
|
||||||
|
{"id": 1, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0000c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.9995], [49.99992, 49.9995], [49.99992, 49.99977], [49.9995, 49.99977], [49.9995, 49.9995]]]}, "centroid_lat": "49.999635", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.930590+00:00", "updated_at": "2026-05-12T12:19:03.930609+00:00"},
|
||||||
|
{"id": 2, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0000c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 49.9995], [50.000339, 49.9995], [50.000339, 49.99977], [49.99992, 49.99977], [49.99992, 49.9995]]]}, "centroid_lat": "49.999635", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.931797+00:00", "updated_at": "2026-05-12T12:19:03.931817+00:00"},
|
||||||
|
{"id": 3, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0000c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.9995], [50.000759, 49.9995], [50.000759, 49.99977], [50.000339, 49.99977], [50.000339, 49.9995]]]}, "centroid_lat": "49.999635", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.931857+00:00", "updated_at": "2026-05-12T12:19:03.931864+00:00"},
|
||||||
|
{"id": 4, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0001c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.99977], [49.99992, 49.99977], [49.99992, 50.00004], [49.9995, 50.00004], [49.9995, 49.99977]]]}, "centroid_lat": "49.999905", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.931899+00:00", "updated_at": "2026-05-12T12:19:03.931906+00:00"},
|
||||||
|
{"id": 5, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0001c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 49.99977], [50.000339, 49.99977], [50.000339, 50.00004], [49.99992, 50.00004], [49.99992, 49.99977]]]}, "centroid_lat": "49.999905", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.931939+00:00", "updated_at": "2026-05-12T12:19:03.931945+00:00"},
|
||||||
|
{"id": 6, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0001c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.99977], [50.000759, 49.99977], [50.000759, 50.00004], [50.000339, 50.00004], [50.000339, 49.99977]]]}, "centroid_lat": "49.999905", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.931978+00:00", "updated_at": "2026-05-12T12:19:03.931985+00:00"},
|
||||||
|
{"id": 7, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0002c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 50.00004], [49.99992, 50.00004], [49.99992, 50.000309], [49.9995, 50.000309], [49.9995, 50.00004]]]}, "centroid_lat": "50.000174", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.932017+00:00", "updated_at": "2026-05-12T12:19:03.932024+00:00"},
|
||||||
|
{"id": 8, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0002c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 50.00004], [50.000339, 50.00004], [50.000339, 50.000309], [49.99992, 50.000309], [49.99992, 50.00004]]]}, "centroid_lat": "50.000174", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.932056+00:00", "updated_at": "2026-05-12T12:19:03.932063+00:00"},
|
||||||
|
{"id": 9, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0002c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 50.00004], [50.000759, 50.00004], [50.000759, 50.000309], [50.000339, 50.000309], [50.000339, 50.00004]]]}, "centroid_lat": "50.000174", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.932100+00:00", "updated_at": "2026-05-12T12:19:03.932107+00:00"},
|
||||||
|
{"id": 10, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0003c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 50.000309], [49.99992, 50.000309], [49.99992, 50.000579], [49.9995, 50.000579], [49.9995, 50.000309]]]}, "centroid_lat": "50.000444", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.932145+00:00", "updated_at": "2026-05-12T12:19:03.932152+00:00"},
|
||||||
|
{"id": 11, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0003c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 50.000309], [50.000339, 50.000309], [50.000339, 50.000579], [49.99992, 50.000579], [49.99992, 50.000309]]]}, "centroid_lat": "50.000444", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.932191+00:00", "updated_at": "2026-05-12T12:19:03.932198+00:00"},
|
||||||
|
{"id": 12, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0003c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 50.000309], [50.000759, 50.000309], [50.000759, 50.000579], [50.000339, 50.000579], [50.000339, 50.000309]]]}, "centroid_lat": "50.000444", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.932234+00:00", "updated_at": "2026-05-12T12:19:03.932241+00:00"},
|
||||||
|
],
|
||||||
|
"analysisgridobservations": [
|
||||||
|
{"id": 1, "cell_id": 1, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6483580933676826, "ndwi": -0.5629512800110711, "soil_vv": -15.369688, "soil_vv_db": -15.369688, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.614257+00:00", "updated_at": "2026-05-12T12:37:26.614281+00:00"},
|
||||||
|
{"id": 2, "cell_id": 2, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6588515573077731, "ndwi": -0.5730775992075602, "soil_vv": -14.043169, "soil_vv_db": -14.043169, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.615124+00:00", "updated_at": "2026-05-12T12:37:26.615143+00:00"},
|
||||||
|
{"id": 3, "cell_id": 3, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6946650213665433, "ndwi": -0.6026291714774238, "soil_vv": -13.727797, "soil_vv_db": -13.727797, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.615867+00:00", "updated_at": "2026-05-12T12:37:26.615885+00:00"},
|
||||||
|
{"id": 4, "cell_id": 4, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6682408054669698, "ndwi": -0.578668495019277, "soil_vv": -13.127913, "soil_vv_db": -13.127913, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.616668+00:00", "updated_at": "2026-05-12T12:37:26.616687+00:00"},
|
||||||
|
{"id": 5, "cell_id": 5, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6642558574676514, "ndwi": -0.5712497035662333, "soil_vv": -12.400669, "soil_vv_db": -12.400669, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.617453+00:00", "updated_at": "2026-05-12T12:37:26.617472+00:00"},
|
||||||
|
{"id": 6, "cell_id": 6, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.7056174145804511, "ndwi": -0.599965857134925, "soil_vv": -12.273758, "soil_vv_db": -12.273758, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.618181+00:00", "updated_at": "2026-05-12T12:37:26.618199+00:00"},
|
||||||
|
{"id": 7, "cell_id": 7, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6824584868219163, "ndwi": -0.5929381317562528, "soil_vv": -12.147284, "soil_vv_db": -12.147284, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.618964+00:00", "updated_at": "2026-05-12T12:37:26.618982+00:00"},
|
||||||
|
{"id": 8, "cell_id": 8, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6956862476136949, "ndwi": -0.6001381145583259, "soil_vv": -13.170681, "soil_vv_db": -13.170681, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.619733+00:00", "updated_at": "2026-05-12T12:37:26.619750+00:00"},
|
||||||
|
{"id": 9, "cell_id": 9, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.7093238963021172, "ndwi": -0.6121659080187479, "soil_vv": -13.873331, "soil_vv_db": -13.873331, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.620504+00:00", "updated_at": "2026-05-12T12:37:26.620522+00:00"},
|
||||||
|
{"id": 10, "cell_id": 10, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6764502127965292, "ndwi": -0.5939946042166816, "soil_vv": -14.09151, "soil_vv_db": -14.09151, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.621257+00:00", "updated_at": "2026-05-12T12:37:26.621275+00:00"},
|
||||||
|
{"id": 11, "cell_id": 11, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6600760486390855, "ndwi": -0.5822326408492194, "soil_vv": -13.272252, "soil_vv_db": -13.272252, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.621989+00:00", "updated_at": "2026-05-12T12:37:26.622008+00:00"},
|
||||||
|
{"id": 12, "cell_id": 12, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6991057925754123, "ndwi": -0.6043583750724792, "soil_vv": -12.991811, "soil_vv_db": -12.991811, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.622751+00:00", "updated_at": "2026-05-12T12:37:26.622769+00:00"},
|
||||||
|
],
|
||||||
|
"remotesensingclusterassignments": [
|
||||||
|
{"id": 1, "result_id": 1, "cell_id": 1, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6483580933676826, "ndwi": -0.5629512800110711, "soil_vv_db": -15.369688}, "scaled_feature_values": {"ndvi": -1.630738, "ndwi": 1.792215, "soil_vv_db": -2.274005}, "created_at": "2026-05-12T12:37:27.901986+00:00", "updated_at": "2026-05-12T12:37:27.902003+00:00"},
|
||||||
|
{"id": 2, "result_id": 1, "cell_id": 2, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6588515573077731, "ndwi": -0.5730775992075602, "soil_vv_db": -14.043169}, "scaled_feature_values": {"ndvi": -1.094298, "ndwi": 1.109414, "soil_vv_db": -0.762373}, "created_at": "2026-05-12T12:37:27.902768+00:00", "updated_at": "2026-05-12T12:37:27.902786+00:00"},
|
||||||
|
{"id": 3, "result_id": 1, "cell_id": 3, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6946650213665433, "ndwi": -0.6026291714774238, "soil_vv_db": -13.727797}, "scaled_feature_values": {"ndvi": 0.736534, "ndwi": -0.8832, "soil_vv_db": -0.402992}, "created_at": "2026-05-12T12:37:27.903479+00:00", "updated_at": "2026-05-12T12:37:27.903497+00:00"},
|
||||||
|
{"id": 4, "result_id": 1, "cell_id": 4, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6682408054669698, "ndwi": -0.578668495019277, "soil_vv_db": -13.127913}, "scaled_feature_values": {"ndvi": -0.614307, "ndwi": 0.732429, "soil_vv_db": 0.280605}, "created_at": "2026-05-12T12:37:27.904197+00:00", "updated_at": "2026-05-12T12:37:27.904214+00:00"},
|
||||||
|
{"id": 5, "result_id": 1, "cell_id": 5, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6642558574676514, "ndwi": -0.5712497035662333, "soil_vv_db": -12.400669}, "scaled_feature_values": {"ndvi": -0.818023, "ndwi": 1.232666, "soil_vv_db": 1.109334}, "created_at": "2026-05-12T12:37:27.904909+00:00", "updated_at": "2026-05-12T12:37:27.904926+00:00"},
|
||||||
|
{"id": 6, "result_id": 1, "cell_id": 6, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.7056174145804511, "ndwi": -0.599965857134925, "soil_vv_db": -12.273758}, "scaled_feature_values": {"ndvi": 1.296436, "ndwi": -0.703617, "soil_vv_db": 1.253955}, "created_at": "2026-05-12T12:37:27.905606+00:00", "updated_at": "2026-05-12T12:37:27.905623+00:00"},
|
||||||
|
{"id": 7, "result_id": 1, "cell_id": 7, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6824584868219163, "ndwi": -0.5929381317562528, "soil_vv_db": -12.147284}, "scaled_feature_values": {"ndvi": 0.11252, "ndwi": -0.229749, "soil_vv_db": 1.398079}, "created_at": "2026-05-12T12:37:27.906325+00:00", "updated_at": "2026-05-12T12:37:27.906343+00:00"},
|
||||||
|
{"id": 8, "result_id": 1, "cell_id": 8, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6956862476136949, "ndwi": -0.6001381145583259, "soil_vv_db": -13.170681}, "scaled_feature_values": {"ndvi": 0.788741, "ndwi": -0.715232, "soil_vv_db": 0.231869}, "created_at": "2026-05-12T12:37:27.907052+00:00", "updated_at": "2026-05-12T12:37:27.907069+00:00"},
|
||||||
|
{"id": 9, "result_id": 1, "cell_id": 9, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.7093238963021172, "ndwi": -0.6121659080187479, "soil_vv_db": -13.873331}, "scaled_feature_values": {"ndvi": 1.485916, "ndwi": -1.526247, "soil_vv_db": -0.568835}, "created_at": "2026-05-12T12:37:27.907735+00:00", "updated_at": "2026-05-12T12:37:27.907752+00:00"},
|
||||||
|
{"id": 10, "result_id": 1, "cell_id": 10, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6764502127965292, "ndwi": -0.5939946042166816, "soil_vv_db": -14.09151}, "scaled_feature_values": {"ndvi": -0.194631, "ndwi": -0.300985, "soil_vv_db": -0.81746}, "created_at": "2026-05-12T12:37:27.908441+00:00", "updated_at": "2026-05-12T12:37:27.908458+00:00"},
|
||||||
|
{"id": 11, "result_id": 1, "cell_id": 11, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6600760486390855, "ndwi": -0.5822326408492194, "soil_vv_db": -13.272252}, "scaled_feature_values": {"ndvi": -1.031701, "ndwi": 0.492105, "soil_vv_db": 0.116124}, "created_at": "2026-05-12T12:37:27.909117+00:00", "updated_at": "2026-05-12T12:37:27.909134+00:00"},
|
||||||
|
{"id": 12, "result_id": 1, "cell_id": 12, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6991057925754123, "ndwi": -0.6043583750724792, "soil_vv_db": -12.991811}, "scaled_feature_values": {"ndvi": 0.963553, "ndwi": -0.999798, "soil_vv_db": 0.4357}, "created_at": "2026-05-12T12:37:27.909828+00:00", "updated_at": "2026-05-12T12:37:27.909845+00:00"},
|
||||||
|
],
|
||||||
|
"remotesensingsubdivisionoptions": [],
|
||||||
|
"remotesensingsubdivisionoptionblocks": [],
|
||||||
|
"remotesensingsubdivisionoptionassignments": [],
|
||||||
|
"ndviobservations": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(value: str | None):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
parsed = parse_datetime(value)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _date(value: str | None):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _decimal(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return Decimal(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid(value):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return UUID(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Seed the current location_data database snapshot into local tables."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--flush-existing",
|
||||||
|
action="store_true",
|
||||||
|
help="Delete existing location_data seeded tables before inserting the snapshot.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if options["flush_existing"]:
|
||||||
|
self._flush_existing()
|
||||||
|
|
||||||
|
self._seed_soil_locations()
|
||||||
|
self._seed_block_subdivisions()
|
||||||
|
self._seed_remote_sensing_runs()
|
||||||
|
self._seed_subdivision_results()
|
||||||
|
self._seed_cluster_blocks()
|
||||||
|
self._seed_analysis_grid_cells()
|
||||||
|
self._seed_analysis_grid_observations()
|
||||||
|
self._seed_cluster_assignments()
|
||||||
|
self._seed_subdivision_options()
|
||||||
|
self._seed_subdivision_option_blocks()
|
||||||
|
self._seed_subdivision_option_assignments()
|
||||||
|
self._seed_ndvi_observations()
|
||||||
|
self.stdout.write(self.style.SUCCESS("location_data seed snapshot applied successfully."))
|
||||||
|
|
||||||
|
def _flush_existing(self):
|
||||||
|
RemoteSensingSubdivisionOptionAssignment.objects.all().delete()
|
||||||
|
RemoteSensingSubdivisionOptionBlock.objects.all().delete()
|
||||||
|
RemoteSensingSubdivisionOption.objects.all().delete()
|
||||||
|
RemoteSensingClusterAssignment.objects.all().delete()
|
||||||
|
AnalysisGridObservation.objects.all().delete()
|
||||||
|
RemoteSensingClusterBlock.objects.all().delete()
|
||||||
|
RemoteSensingSubdivisionResult.objects.all().delete()
|
||||||
|
RemoteSensingRun.objects.all().delete()
|
||||||
|
AnalysisGridCell.objects.all().delete()
|
||||||
|
BlockSubdivision.objects.all().delete()
|
||||||
|
NdviObservation.objects.all().delete()
|
||||||
|
SoilLocation.objects.all().delete()
|
||||||
|
|
||||||
|
def _seed_soil_locations(self):
|
||||||
|
for row in SEED_DATA["soillocations"]:
|
||||||
|
obj, _ = SoilLocation.objects.update_or_create(
|
||||||
|
id=row["id"],
|
||||||
|
defaults={
|
||||||
|
"latitude": _decimal(row["latitude"]),
|
||||||
|
"longitude": _decimal(row["longitude"]),
|
||||||
|
"task_id": row["task_id"],
|
||||||
|
"farm_boundary": row["farm_boundary"],
|
||||||
|
"input_block_count": row["input_block_count"],
|
||||||
|
"block_layout": row["block_layout"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._touch(obj, row)
|
||||||
|
|
||||||
|
def _seed_block_subdivisions(self):
|
||||||
|
for row in SEED_DATA["blocksubdivisions"]:
|
||||||
|
obj, _ = BlockSubdivision.objects.update_or_create(
|
||||||
|
id=row["id"],
|
||||||
|
defaults={
|
||||||
|
"soil_location_id": row["soil_location_id"],
|
||||||
|
"block_code": row["block_code"],
|
||||||
|
"source_boundary": row["source_boundary"],
|
||||||
|
"chunk_size_sqm": row["chunk_size_sqm"],
|
||||||
|
"grid_points": row["grid_points"],
|
||||||
|
"centroid_points": row["centroid_points"],
|
||||||
|
"grid_point_count": row["grid_point_count"],
|
||||||
|
"centroid_count": row["centroid_count"],
|
||||||
|
"elbow_plot": row.get("elbow_plot", ""),
|
||||||
|
"subdivision_summary": row.get("subdivision_summary", {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._touch(obj, row)
|
||||||
|
|
||||||
|
def _seed_remote_sensing_runs(self):
|
||||||
|
for row in SEED_DATA["remotesensingruns"]:
|
||||||
|
obj, _ = RemoteSensingRun.objects.update_or_create(
|
||||||
|
id=row["id"],
|
||||||
|
defaults={
|
||||||
|
"soil_location_id": row["soil_location_id"],
|
||||||
|
"block_subdivision_id": row["block_subdivision_id"],
|
||||||
|
"block_code": row["block_code"],
|
||||||
|
"provider": row["provider"],
|
||||||
|
"chunk_size_sqm": row["chunk_size_sqm"],
|
||||||
|
"temporal_start": _date(row["temporal_start"]),
|
||||||
|
"temporal_end": _date(row["temporal_end"]),
|
||||||
|
"status": row["status"],
|
||||||
|
"metadata": row["metadata"],
|
||||||
|
"error_message": row["error_message"],
|
||||||
|
"started_at": _dt(row["started_at"]),
|
||||||
|
"finished_at": _dt(row["finished_at"]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._touch(obj, row)
|
||||||
|
|
||||||
|
def _seed_subdivision_results(self):
|
||||||
|
for row in SEED_DATA["remotesensingsubdivisionresults"]:
|
||||||
|
obj, _ = RemoteSensingSubdivisionResult.objects.update_or_create(
|
||||||
|
id=row["id"],
|
||||||
|
defaults={
|
||||||
|
"soil_location_id": row["soil_location_id"],
|
||||||
|
"run_id": row["run_id"],
|
||||||
|
"block_subdivision_id": row["block_subdivision_id"],
|
||||||
|
"block_code": row["block_code"],
|
||||||
|
"chunk_size_sqm": row["chunk_size_sqm"],
|
||||||
|
"temporal_start": _date(row["temporal_start"]),
|
||||||
|
"temporal_end": _date(row["temporal_end"]),
|
||||||
|
"cluster_count": row["cluster_count"],
|
||||||
|
"selected_features": row["selected_features"],
|
||||||
|
"skipped_cell_codes": row["skipped_cell_codes"],
|
||||||
|
"metadata": row["metadata"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._touch(obj, row)
|
||||||
|
|
||||||
|
def _seed_cluster_blocks(self):
|
||||||
|
for row in SEED_DATA["remotesensingclusterblocks"]:
|
||||||
|
obj, _ = RemoteSensingClusterBlock.objects.update_or_create(
|
||||||
|
id=row["id"],
|
||||||
|
defaults={
|
||||||
|
"uuid": _uuid(row["uuid"]),
|
||||||
|
"result_id": row["result_id"],
|
||||||
|
"soil_location_id": row["soil_location_id"],
|
||||||
|
"block_subdivision_id": row["block_subdivision_id"],
|
||||||
|
"block_code": row["block_code"],
|
||||||
|
"sub_block_code": row["sub_block_code"],
|
||||||
|
"cluster_label": row["cluster_label"],
|
||||||
|
"chunk_size_sqm": row["chunk_size_sqm"],
|
||||||
|
"centroid_lat": _decimal(row["centroid_lat"]),
|
||||||
|
"centroid_lon": _decimal(row["centroid_lon"]),
|
||||||
|
"center_cell_code": row["center_cell_code"],
|
||||||
|
"center_cell_lat": _decimal(row["center_cell_lat"]),
|
||||||
|
"center_cell_lon": _decimal(row["center_cell_lon"]),
|
||||||
|
"geometry": row["geometry"],
|
||||||
|
"cell_count": row["cell_count"],
|
||||||
|
"cell_codes": row["cell_codes"],
|
||||||
|
"metadata": row["metadata"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._touch(obj, row)
|
||||||
|
|
||||||
|
def _seed_analysis_grid_cells(self):
|
||||||
|
for row in SEED_DATA["analysisgridcells"]:
|
||||||
|
obj, _ = AnalysisGridCell.objects.update_or_create(
|
||||||
|
id=row["id"],
|
||||||
|
defaults={
|
||||||
|
"soil_location_id": row["soil_location_id"],
|
||||||
|
"block_subdivision_id": row["block_subdivision_id"],
|
||||||
|
"block_code": row["block_code"],
|
||||||
|
"cell_code": row["cell_code"],
|
||||||
|
"chunk_size_sqm": row["chunk_size_sqm"],
|
||||||
|
"geometry": row["geometry"],
|
||||||
|
"centroid_lat": _decimal(row["centroid_lat"]),
|
||||||
|
"centroid_lon": _decimal(row["centroid_lon"]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._touch(obj, row)
|
||||||
|
|
||||||
|
def _seed_analysis_grid_observations(self):
|
||||||
|
for row in SEED_DATA["analysisgridobservations"]:
|
||||||
|
obj, _ = AnalysisGridObservation.objects.update_or_create(
|
||||||
|
id=row["id"],
|
||||||
|
defaults={
|
||||||
|
"cell_id": row["cell_id"],
|
||||||
|
"run_id": row["run_id"],
|
||||||
|
"temporal_start": _date(row["temporal_start"]),
|
||||||
|
"temporal_end": _date(row["temporal_end"]),
|
||||||
|
"ndvi": row["ndvi"],
|
||||||
|
"ndwi": row["ndwi"],
|
||||||
|
"soil_vv": row["soil_vv"],
|
||||||
|
"soil_vv_db": row["soil_vv_db"],
|
||||||
|
"dem_m": row["dem_m"],
|
||||||
|
"slope_deg": row["slope_deg"],
|
||||||
|
"metadata": row["metadata"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._touch(obj, row)
|
||||||
|
|
||||||
|
def _seed_cluster_assignments(self):
|
||||||
|
for row in SEED_DATA["remotesensingclusterassignments"]:
|
||||||
|
obj, _ = RemoteSensingClusterAssignment.objects.update_or_create(
|
||||||
|
id=row["id"],
|
||||||
|
defaults={
|
||||||
|
"result_id": row["result_id"],
|
||||||
|
"cell_id": row["cell_id"],
|
||||||
|
"cluster_label": row["cluster_label"],
|
||||||
|
"raw_feature_values": row["raw_feature_values"],
|
||||||
|
"scaled_feature_values": row["scaled_feature_values"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._touch(obj, row)
|
||||||
|
|
||||||
|
def _seed_subdivision_options(self):
|
||||||
|
for row in SEED_DATA["remotesensingsubdivisionoptions"]:
|
||||||
|
obj, _ = RemoteSensingSubdivisionOption.objects.update_or_create(
|
||||||
|
id=row["id"],
|
||||||
|
defaults={
|
||||||
|
"result_id": row["result_id"],
|
||||||
|
"requested_k": row["requested_k"],
|
||||||
|
"effective_cluster_count": row["effective_cluster_count"],
|
||||||
|
"is_active": row["is_active"],
|
||||||
|
"is_recommended": row["is_recommended"],
|
||||||
|
"selection_source": row["selection_source"],
|
||||||
|
"metadata": row["metadata"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._touch(obj, row)
|
||||||
|
|
||||||
|
def _seed_subdivision_option_blocks(self):
|
||||||
|
for row in SEED_DATA["remotesensingsubdivisionoptionblocks"]:
|
||||||
|
obj, _ = RemoteSensingSubdivisionOptionBlock.objects.update_or_create(
|
||||||
|
id=row["id"],
|
||||||
|
defaults={
|
||||||
|
"option_id": row["option_id"],
|
||||||
|
"cluster_label": row["cluster_label"],
|
||||||
|
"sub_block_code": row["sub_block_code"],
|
||||||
|
"chunk_size_sqm": row["chunk_size_sqm"],
|
||||||
|
"centroid_lat": _decimal(row["centroid_lat"]),
|
||||||
|
"centroid_lon": _decimal(row["centroid_lon"]),
|
||||||
|
"center_cell_code": row["center_cell_code"],
|
||||||
|
"center_cell_lat": _decimal(row["center_cell_lat"]),
|
||||||
|
"center_cell_lon": _decimal(row["center_cell_lon"]),
|
||||||
|
"geometry": row["geometry"],
|
||||||
|
"cell_count": row["cell_count"],
|
||||||
|
"cell_codes": row["cell_codes"],
|
||||||
|
"metadata": row["metadata"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._touch(obj, row)
|
||||||
|
|
||||||
|
def _seed_subdivision_option_assignments(self):
|
||||||
|
for row in SEED_DATA["remotesensingsubdivisionoptionassignments"]:
|
||||||
|
obj, _ = RemoteSensingSubdivisionOptionAssignment.objects.update_or_create(
|
||||||
|
id=row["id"],
|
||||||
|
defaults={
|
||||||
|
"option_id": row["option_id"],
|
||||||
|
"cell_id": row["cell_id"],
|
||||||
|
"cluster_label": row["cluster_label"],
|
||||||
|
"raw_feature_values": row["raw_feature_values"],
|
||||||
|
"scaled_feature_values": row["scaled_feature_values"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._touch(obj, row)
|
||||||
|
|
||||||
|
def _seed_ndvi_observations(self):
|
||||||
|
for row in SEED_DATA["ndviobservations"]:
|
||||||
|
obj, _ = NdviObservation.objects.update_or_create(
|
||||||
|
id=row["id"],
|
||||||
|
defaults={
|
||||||
|
"location_id": row["location_id"],
|
||||||
|
"observation_date": _date(row["observation_date"]),
|
||||||
|
"mean_ndvi": row["mean_ndvi"],
|
||||||
|
"ndvi_map": row["ndvi_map"],
|
||||||
|
"vegetation_health_class": row["vegetation_health_class"],
|
||||||
|
"satellite_source": row["satellite_source"],
|
||||||
|
"cloud_cover": row["cloud_cover"],
|
||||||
|
"metadata": row["metadata"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._touch(obj, row, created_field=False)
|
||||||
|
|
||||||
|
def _touch(self, obj, row, *, created_field=True):
|
||||||
|
updates = []
|
||||||
|
if created_field and hasattr(obj, "created_at") and row.get("created_at"):
|
||||||
|
obj.created_at = _dt(row["created_at"])
|
||||||
|
updates.append("created_at")
|
||||||
|
if hasattr(obj, "updated_at") and row.get("updated_at"):
|
||||||
|
obj.updated_at = _dt(row["updated_at"])
|
||||||
|
updates.append("updated_at")
|
||||||
|
if updates:
|
||||||
|
obj.save(update_fields=updates)
|
||||||
@@ -3,24 +3,83 @@ import uuid
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
def build_default_sub_block(block_code: str, *, boundary: dict | None = None) -> dict:
|
||||||
|
normalized_block_code = str(block_code or "block-1").strip() or "block-1"
|
||||||
|
return {
|
||||||
|
"sub_block_code": f"{normalized_block_code}-sub-1",
|
||||||
|
"cluster_label": 0,
|
||||||
|
"source": "default",
|
||||||
|
"boundary": boundary or {},
|
||||||
|
"cluster_uuid": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_block_layout_defaults(layout: dict | None, *, block_count: int | None = None) -> dict:
|
||||||
|
raw_layout = dict(layout or {})
|
||||||
|
raw_blocks = list(raw_layout.get("blocks") or [])
|
||||||
|
normalized_count = len(raw_blocks) if raw_blocks else max(int(block_count or raw_layout.get("input_block_count") or 1), 1)
|
||||||
|
normalized_blocks: list[dict] = []
|
||||||
|
|
||||||
|
for index in range(normalized_count):
|
||||||
|
raw_block = raw_blocks[index] if index < len(raw_blocks) else {}
|
||||||
|
block_code = str(raw_block.get("block_code") or f"block-{index + 1}").strip() or f"block-{index + 1}"
|
||||||
|
boundary = raw_block.get("boundary") or {}
|
||||||
|
sub_blocks = [dict(sub_block) for sub_block in (raw_block.get("sub_blocks") or []) if isinstance(sub_block, dict)]
|
||||||
|
if not sub_blocks:
|
||||||
|
sub_blocks = [build_default_sub_block(block_code, boundary=boundary)]
|
||||||
|
|
||||||
|
normalized_block = {
|
||||||
|
"block_code": block_code,
|
||||||
|
"order": int(raw_block.get("order") or index + 1),
|
||||||
|
"source": raw_block.get("source") or ("input" if raw_blocks or normalized_count > 1 else "default"),
|
||||||
|
"boundary": boundary,
|
||||||
|
"needs_subdivision": raw_block.get("needs_subdivision"),
|
||||||
|
"sub_blocks": sub_blocks,
|
||||||
|
}
|
||||||
|
for extra_key in ("subdivision_summary", "analysis_grid_summary", "aggregated_metrics"):
|
||||||
|
if extra_key in raw_block:
|
||||||
|
normalized_block[extra_key] = raw_block[extra_key]
|
||||||
|
normalized_blocks.append(normalized_block)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"input_block_count": normalized_count,
|
||||||
|
"default_full_farm": raw_layout.get("default_full_farm", normalized_count == 1),
|
||||||
|
"algorithm_status": raw_layout.get("algorithm_status") or "pending",
|
||||||
|
"blocks": normalized_blocks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def build_block_layout(block_count: int = 1, blocks: list[dict] | None = None) -> dict:
|
def build_block_layout(block_count: int = 1, blocks: list[dict] | None = None) -> dict:
|
||||||
normalized_blocks = []
|
|
||||||
if blocks:
|
if blocks:
|
||||||
for index, block in enumerate(blocks):
|
return ensure_block_layout_defaults(
|
||||||
normalized_blocks.append(
|
{
|
||||||
|
"input_block_count": len(blocks),
|
||||||
|
"default_full_farm": len(blocks) == 1,
|
||||||
|
"algorithm_status": "pending",
|
||||||
|
"blocks": [
|
||||||
{
|
{
|
||||||
"block_code": str(block.get("block_code") or f"block-{index + 1}").strip(),
|
"block_code": str(block.get("block_code") or f"block-{index + 1}").strip(),
|
||||||
"order": int(block.get("order") or index + 1),
|
"order": int(block.get("order") or index + 1),
|
||||||
"source": "input",
|
"source": "input",
|
||||||
"boundary": block.get("boundary") or {},
|
"boundary": block.get("boundary") or {},
|
||||||
"needs_subdivision": None,
|
"needs_subdivision": None,
|
||||||
"sub_blocks": [],
|
"sub_blocks": [dict(sub_block) for sub_block in (block.get("sub_blocks") or [])],
|
||||||
}
|
}
|
||||||
|
for index, block in enumerate(blocks)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
block_count=len(blocks),
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
normalized_count = max(int(block_count or 1), 1)
|
normalized_count = max(int(block_count or 1), 1)
|
||||||
for index in range(normalized_count):
|
return ensure_block_layout_defaults(
|
||||||
normalized_blocks.append(
|
{
|
||||||
|
"input_block_count": normalized_count,
|
||||||
|
"default_full_farm": normalized_count == 1,
|
||||||
|
"algorithm_status": "pending",
|
||||||
|
"blocks": [
|
||||||
{
|
{
|
||||||
"block_code": f"block-{index + 1}",
|
"block_code": f"block-{index + 1}",
|
||||||
"order": index + 1,
|
"order": index + 1,
|
||||||
@@ -29,17 +88,12 @@ def build_block_layout(block_count: int = 1, blocks: list[dict] | None = None) -
|
|||||||
"needs_subdivision": None,
|
"needs_subdivision": None,
|
||||||
"sub_blocks": [],
|
"sub_blocks": [],
|
||||||
}
|
}
|
||||||
|
for index in range(normalized_count)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
block_count=normalized_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
normalized_count = len(normalized_blocks) if normalized_blocks else max(int(block_count or 1), 1)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"input_block_count": normalized_count,
|
|
||||||
"default_full_farm": normalized_count == 1,
|
|
||||||
"algorithm_status": "pending",
|
|
||||||
"blocks": normalized_blocks,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SoilLocation(models.Model):
|
class SoilLocation(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -122,8 +176,10 @@ class SoilLocation(models.Model):
|
|||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.input_block_count:
|
if not self.input_block_count:
|
||||||
self.input_block_count = 1
|
self.input_block_count = 1
|
||||||
if not self.block_layout:
|
self.block_layout = ensure_block_layout_defaults(
|
||||||
self.block_layout = build_block_layout(self.input_block_count)
|
self.block_layout,
|
||||||
|
block_count=self.input_block_count,
|
||||||
|
)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
from django.db.models import Avg, QuerySet
|
from django.db.models import Avg, QuerySet
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
|
ensure_block_layout_defaults,
|
||||||
AnalysisGridObservation,
|
AnalysisGridObservation,
|
||||||
RemoteSensingRun,
|
RemoteSensingRun,
|
||||||
RemoteSensingSubdivisionResult,
|
RemoteSensingSubdivisionResult,
|
||||||
@@ -90,7 +91,7 @@ def build_location_block_satellite_snapshots(
|
|||||||
*,
|
*,
|
||||||
sensor_payload: dict[str, Any] | None = None,
|
sensor_payload: dict[str, Any] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
block_layout = location.block_layout or {}
|
block_layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
|
||||||
blocks = block_layout.get("blocks") or []
|
blocks = block_layout.get("blocks") or []
|
||||||
if not blocks:
|
if not blocks:
|
||||||
return [build_location_satellite_snapshot(location, sensor_payload=sensor_payload)]
|
return [build_location_satellite_snapshot(location, sensor_payload=sensor_payload)]
|
||||||
@@ -112,7 +113,7 @@ def build_block_layout_metric_summary(
|
|||||||
*,
|
*,
|
||||||
sensor_payload: dict[str, Any] | None = None,
|
sensor_payload: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
layout = dict(location.block_layout or {})
|
layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
|
||||||
blocks = [dict(block) for block in (layout.get("blocks") or [])]
|
blocks = [dict(block) for block in (layout.get("blocks") or [])]
|
||||||
snapshots_by_block_code = {
|
snapshots_by_block_code = {
|
||||||
str(snapshot.get("block_code") or ""): snapshot
|
str(snapshot.get("block_code") or ""): snapshot
|
||||||
@@ -461,7 +462,7 @@ def build_block_sensor_summary(
|
|||||||
|
|
||||||
|
|
||||||
def _build_active_sub_block_lookup(location: SoilLocation) -> dict[str, Any]:
|
def _build_active_sub_block_lookup(location: SoilLocation) -> dict[str, Any]:
|
||||||
block_layout = dict(location.block_layout or {})
|
block_layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count)
|
||||||
by_cluster_uuid: dict[str, dict[str, Any]] = {}
|
by_cluster_uuid: dict[str, dict[str, Any]] = {}
|
||||||
by_sub_block_code: dict[str, list[dict[str, Any]]] = {}
|
by_sub_block_code: dict[str, list[dict[str, Any]]] = {}
|
||||||
by_block_and_cluster_label: dict[tuple[str, int], dict[str, Any]] = {}
|
by_block_and_cluster_label: dict[tuple[str, int], dict[str, Any]] = {}
|
||||||
|
|||||||
@@ -144,6 +144,10 @@ class RemoteSensingFarmRequestSerializer(serializers.Serializer):
|
|||||||
page_size = serializers.IntegerField(required=False, min_value=1, max_value=200, default=100)
|
page_size = serializers.IntegerField(required=False, min_value=1, max_value=200, default=100)
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterCropRecommendationRequestSerializer(serializers.Serializer):
|
||||||
|
farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه")
|
||||||
|
|
||||||
|
|
||||||
class RemoteSensingClusterBlockLiveRequestSerializer(serializers.Serializer):
|
class RemoteSensingClusterBlockLiveRequestSerializer(serializers.Serializer):
|
||||||
temporal_start = serializers.DateField(required=False)
|
temporal_start = serializers.DateField(required=False)
|
||||||
temporal_end = serializers.DateField(required=False)
|
temporal_end = serializers.DateField(required=False)
|
||||||
@@ -426,6 +430,54 @@ class RemoteSensingClusterBlockLiveResponseSerializer(serializers.Serializer):
|
|||||||
metadata = serializers.JSONField()
|
metadata = serializers.JSONField()
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterCropRegisteredPlantSerializer(serializers.Serializer):
|
||||||
|
plant_id = serializers.IntegerField()
|
||||||
|
plant_name = serializers.CharField()
|
||||||
|
position = serializers.IntegerField(allow_null=True)
|
||||||
|
stage = serializers.CharField(allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterCropCandidateSerializer(serializers.Serializer):
|
||||||
|
plant_id = serializers.IntegerField(allow_null=True)
|
||||||
|
plant_name = serializers.CharField()
|
||||||
|
position = serializers.IntegerField(allow_null=True)
|
||||||
|
stage = serializers.CharField(allow_blank=True)
|
||||||
|
score = serializers.FloatField()
|
||||||
|
predicted_yield = serializers.FloatField(allow_null=True)
|
||||||
|
predicted_yield_tons = serializers.FloatField(allow_null=True)
|
||||||
|
biomass = serializers.FloatField(allow_null=True)
|
||||||
|
max_lai = serializers.FloatField(allow_null=True)
|
||||||
|
simulation_engine = serializers.CharField(allow_null=True)
|
||||||
|
simulation_model_name = serializers.CharField(allow_null=True)
|
||||||
|
simulation_warning = serializers.CharField(allow_null=True, allow_blank=True)
|
||||||
|
supporting_metrics = serializers.JSONField()
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterCropRecommendationClusterSerializer(serializers.Serializer):
|
||||||
|
block_code = serializers.CharField(allow_blank=True)
|
||||||
|
cluster_uuid = serializers.CharField(allow_null=True, allow_blank=True)
|
||||||
|
sub_block_code = serializers.CharField()
|
||||||
|
cluster_label = serializers.IntegerField(allow_null=True)
|
||||||
|
temporal_extent = serializers.JSONField(allow_null=True)
|
||||||
|
cluster_block = RemoteSensingClusterBlockSerializer(allow_null=True)
|
||||||
|
satellite_metrics = serializers.JSONField()
|
||||||
|
sensor_metrics = serializers.JSONField()
|
||||||
|
resolved_metrics = serializers.JSONField()
|
||||||
|
candidate_plants = ClusterCropCandidateSerializer(many=True)
|
||||||
|
suggested_plant = ClusterCropCandidateSerializer(allow_null=True)
|
||||||
|
source_metadata = serializers.JSONField()
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterCropRecommendationResponseSerializer(serializers.Serializer):
|
||||||
|
farm_uuid = serializers.CharField()
|
||||||
|
location_id = serializers.IntegerField()
|
||||||
|
evaluated_plant_count = serializers.IntegerField()
|
||||||
|
cluster_count = serializers.IntegerField()
|
||||||
|
registered_plants = ClusterCropRegisteredPlantSerializer(many=True)
|
||||||
|
clusters = ClusterCropRecommendationClusterSerializer(many=True)
|
||||||
|
source_metadata = serializers.JSONField()
|
||||||
|
|
||||||
|
|
||||||
class RemoteSensingSubdivisionOptionListResponseSerializer(serializers.Serializer):
|
class RemoteSensingSubdivisionOptionListResponseSerializer(serializers.Serializer):
|
||||||
result_id = serializers.IntegerField()
|
result_id = serializers.IntegerField()
|
||||||
active_requested_k = serializers.IntegerField(allow_null=True)
|
active_requested_k = serializers.IntegerField(allow_null=True)
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
from datetime import date
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from farm_data.models import FarmPlantAssignment, PlantCatalogSnapshot, SensorData
|
||||||
|
from location_data.models import (
|
||||||
|
AnalysisGridCell,
|
||||||
|
AnalysisGridObservation,
|
||||||
|
BlockSubdivision,
|
||||||
|
RemoteSensingClusterAssignment,
|
||||||
|
RemoteSensingClusterBlock,
|
||||||
|
RemoteSensingRun,
|
||||||
|
RemoteSensingSubdivisionResult,
|
||||||
|
SoilLocation,
|
||||||
|
)
|
||||||
|
from weather.models import WeatherForecast
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROOT_URLCONF="location_data.urls")
|
||||||
|
class RemoteSensingClusterRecommendationApiTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.boundary = {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [
|
||||||
|
[
|
||||||
|
[51.3890, 35.6890],
|
||||||
|
[51.3900, 35.6890],
|
||||||
|
[51.3900, 35.6900],
|
||||||
|
[51.3890, 35.6900],
|
||||||
|
[51.3890, 35.6890],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
self.location = SoilLocation.objects.create(
|
||||||
|
latitude="35.689200",
|
||||||
|
longitude="51.389000",
|
||||||
|
farm_boundary=self.boundary,
|
||||||
|
)
|
||||||
|
self.location.set_input_block_count(1)
|
||||||
|
self.location.save(update_fields=["input_block_count", "block_layout", "updated_at"])
|
||||||
|
self.farm = SensorData.objects.create(
|
||||||
|
farm_uuid="11111111-1111-1111-1111-111111111111",
|
||||||
|
center_location=self.location,
|
||||||
|
sensor_payload={},
|
||||||
|
)
|
||||||
|
for day_index in range(1, 5):
|
||||||
|
WeatherForecast.objects.create(
|
||||||
|
location=self.location,
|
||||||
|
forecast_date=date(2025, 2, day_index),
|
||||||
|
temperature_min=12.0,
|
||||||
|
temperature_max=24.0,
|
||||||
|
temperature_mean=18.0,
|
||||||
|
precipitation=1.0,
|
||||||
|
precipitation_probability=25.0,
|
||||||
|
humidity_mean=55.0,
|
||||||
|
wind_speed_max=10.0,
|
||||||
|
et0=3.0,
|
||||||
|
weather_code=1,
|
||||||
|
)
|
||||||
|
self.subdivision = BlockSubdivision.objects.create(
|
||||||
|
soil_location=self.location,
|
||||||
|
block_code="block-1",
|
||||||
|
source_boundary=self.boundary,
|
||||||
|
chunk_size_sqm=900,
|
||||||
|
status="subdivided",
|
||||||
|
)
|
||||||
|
self.run = RemoteSensingRun.objects.create(
|
||||||
|
soil_location=self.location,
|
||||||
|
block_subdivision=self.subdivision,
|
||||||
|
block_code="block-1",
|
||||||
|
chunk_size_sqm=900,
|
||||||
|
temporal_start=date(2025, 1, 1),
|
||||||
|
temporal_end=date(2025, 1, 31),
|
||||||
|
status=RemoteSensingRun.STATUS_SUCCESS,
|
||||||
|
metadata={"stage": "completed"},
|
||||||
|
)
|
||||||
|
self.result = RemoteSensingSubdivisionResult.objects.create(
|
||||||
|
soil_location=self.location,
|
||||||
|
run=self.run,
|
||||||
|
block_subdivision=self.subdivision,
|
||||||
|
block_code="block-1",
|
||||||
|
chunk_size_sqm=900,
|
||||||
|
temporal_start=date(2025, 1, 1),
|
||||||
|
temporal_end=date(2025, 1, 31),
|
||||||
|
cluster_count=2,
|
||||||
|
selected_features=["ndvi", "ndwi", "soil_vv_db"],
|
||||||
|
metadata={"used_cell_count": 2, "skipped_cell_count": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.cell_1 = AnalysisGridCell.objects.create(
|
||||||
|
soil_location=self.location,
|
||||||
|
block_subdivision=self.subdivision,
|
||||||
|
block_code="block-1",
|
||||||
|
cell_code="cell-1",
|
||||||
|
chunk_size_sqm=900,
|
||||||
|
geometry=self.boundary,
|
||||||
|
centroid_lat="35.689250",
|
||||||
|
centroid_lon="51.389250",
|
||||||
|
)
|
||||||
|
self.cell_2 = AnalysisGridCell.objects.create(
|
||||||
|
soil_location=self.location,
|
||||||
|
block_subdivision=self.subdivision,
|
||||||
|
block_code="block-1",
|
||||||
|
cell_code="cell-2",
|
||||||
|
chunk_size_sqm=900,
|
||||||
|
geometry=self.boundary,
|
||||||
|
centroid_lat="35.689750",
|
||||||
|
centroid_lon="51.389750",
|
||||||
|
)
|
||||||
|
AnalysisGridObservation.objects.create(
|
||||||
|
cell=self.cell_1,
|
||||||
|
run=self.run,
|
||||||
|
temporal_start=date(2025, 1, 1),
|
||||||
|
temporal_end=date(2025, 1, 31),
|
||||||
|
ndvi=0.51,
|
||||||
|
ndwi=0.24,
|
||||||
|
soil_vv=0.13,
|
||||||
|
soil_vv_db=-10.0,
|
||||||
|
metadata={"backend_name": "openeo"},
|
||||||
|
)
|
||||||
|
AnalysisGridObservation.objects.create(
|
||||||
|
cell=self.cell_2,
|
||||||
|
run=self.run,
|
||||||
|
temporal_start=date(2025, 1, 1),
|
||||||
|
temporal_end=date(2025, 1, 31),
|
||||||
|
ndvi=0.71,
|
||||||
|
ndwi=0.48,
|
||||||
|
soil_vv=0.19,
|
||||||
|
soil_vv_db=-7.5,
|
||||||
|
metadata={"backend_name": "openeo"},
|
||||||
|
)
|
||||||
|
RemoteSensingClusterAssignment.objects.create(
|
||||||
|
result=self.result,
|
||||||
|
cell=self.cell_1,
|
||||||
|
cluster_label=0,
|
||||||
|
raw_feature_values={"ndvi": 0.51, "ndwi": 0.24, "soil_vv_db": -10.0},
|
||||||
|
scaled_feature_values={"ndvi": -1.0, "ndwi": -1.0, "soil_vv_db": -1.0},
|
||||||
|
)
|
||||||
|
RemoteSensingClusterAssignment.objects.create(
|
||||||
|
result=self.result,
|
||||||
|
cell=self.cell_2,
|
||||||
|
cluster_label=1,
|
||||||
|
raw_feature_values={"ndvi": 0.71, "ndwi": 0.48, "soil_vv_db": -7.5},
|
||||||
|
scaled_feature_values={"ndvi": 1.0, "ndwi": 1.0, "soil_vv_db": 1.0},
|
||||||
|
)
|
||||||
|
self.cluster_0 = RemoteSensingClusterBlock.objects.create(
|
||||||
|
result=self.result,
|
||||||
|
soil_location=self.location,
|
||||||
|
block_subdivision=self.subdivision,
|
||||||
|
block_code="block-1",
|
||||||
|
sub_block_code="cluster-0",
|
||||||
|
cluster_label=0,
|
||||||
|
chunk_size_sqm=900,
|
||||||
|
centroid_lat="35.689250",
|
||||||
|
centroid_lon="51.389250",
|
||||||
|
center_cell_code="cell-1",
|
||||||
|
cell_count=1,
|
||||||
|
cell_codes=["cell-1"],
|
||||||
|
geometry=self.boundary,
|
||||||
|
)
|
||||||
|
self.cluster_1 = RemoteSensingClusterBlock.objects.create(
|
||||||
|
result=self.result,
|
||||||
|
soil_location=self.location,
|
||||||
|
block_subdivision=self.subdivision,
|
||||||
|
block_code="block-1",
|
||||||
|
sub_block_code="cluster-1",
|
||||||
|
cluster_label=1,
|
||||||
|
chunk_size_sqm=900,
|
||||||
|
centroid_lat="35.689750",
|
||||||
|
centroid_lon="51.389750",
|
||||||
|
center_cell_code="cell-2",
|
||||||
|
cell_count=1,
|
||||||
|
cell_codes=["cell-2"],
|
||||||
|
geometry=self.boundary,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.location.block_layout = {
|
||||||
|
"input_block_count": 1,
|
||||||
|
"default_full_farm": True,
|
||||||
|
"algorithm_status": "completed",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"block_code": "block-1",
|
||||||
|
"order": 1,
|
||||||
|
"source": "input",
|
||||||
|
"boundary": self.boundary,
|
||||||
|
"needs_subdivision": True,
|
||||||
|
"sub_blocks": [
|
||||||
|
{
|
||||||
|
"sub_block_code": "cluster-0",
|
||||||
|
"cluster_label": 0,
|
||||||
|
"cluster_uuid": str(self.cluster_0.uuid),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sub_block_code": "cluster-1",
|
||||||
|
"cluster_label": 1,
|
||||||
|
"cluster_uuid": str(self.cluster_1.uuid),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
self.location.save(update_fields=["block_layout", "updated_at"])
|
||||||
|
|
||||||
|
self.tomato = PlantCatalogSnapshot.objects.create(
|
||||||
|
backend_plant_id=101,
|
||||||
|
name="Tomato",
|
||||||
|
growth_profile={"simulation": {"crop_parameters": {"crop_name": "Tomato", "MAX_BIOMASS": 14000.0}}},
|
||||||
|
)
|
||||||
|
self.wheat = PlantCatalogSnapshot.objects.create(
|
||||||
|
backend_plant_id=102,
|
||||||
|
name="Wheat",
|
||||||
|
growth_profile={"simulation": {"crop_parameters": {"crop_name": "Wheat", "MAX_BIOMASS": 11000.0}}},
|
||||||
|
)
|
||||||
|
FarmPlantAssignment.objects.create(farm=self.farm, plant=self.tomato, position=0, stage="vegetative")
|
||||||
|
FarmPlantAssignment.objects.create(farm=self.farm, plant=self.wheat, position=1, stage="vegetative")
|
||||||
|
|
||||||
|
@patch("location_data.cluster_recommendation._simulate_candidate")
|
||||||
|
def test_cluster_recommendations_return_ranked_plants_for_each_cluster(self, simulate_mock):
|
||||||
|
def fake_simulation(*, base_payload, soil_parameters, site_parameters):
|
||||||
|
plant_name = base_payload["crop_parameters"]["crop_name"]
|
||||||
|
smfcf = float(soil_parameters["SMFCF"])
|
||||||
|
if plant_name == "Tomato":
|
||||||
|
yield_estimate = 150.0 if smfcf >= 0.4 else 80.0
|
||||||
|
else:
|
||||||
|
yield_estimate = 110.0 if smfcf >= 0.4 else 120.0
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"engine": "pcse",
|
||||||
|
"model_name": "Wofost81_NWLP_CWB_CNB",
|
||||||
|
"metrics": {
|
||||||
|
"yield_estimate": yield_estimate,
|
||||||
|
"biomass": yield_estimate * 2,
|
||||||
|
"max_lai": 4.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
simulate_mock.side_effect = fake_simulation
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/remote-sensing/cluster-recommendations/",
|
||||||
|
data={"farm_uuid": str(self.farm.farm_uuid)},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()["data"]
|
||||||
|
self.assertEqual(payload["cluster_count"], 2)
|
||||||
|
self.assertEqual(payload["evaluated_plant_count"], 2)
|
||||||
|
self.assertEqual(len(payload["registered_plants"]), 2)
|
||||||
|
|
||||||
|
clusters = {item["cluster_label"]: item for item in payload["clusters"]}
|
||||||
|
self.assertEqual(clusters[0]["resolved_metrics"]["ndvi"], 0.51)
|
||||||
|
self.assertEqual(clusters[0]["resolved_metrics"]["ndwi"], 0.24)
|
||||||
|
self.assertEqual(clusters[0]["resolved_metrics"]["soil_vv"], 0.13)
|
||||||
|
self.assertEqual(clusters[1]["resolved_metrics"]["ndwi"], 0.48)
|
||||||
|
|
||||||
|
self.assertEqual(clusters[0]["suggested_plant"]["plant_name"], "Wheat")
|
||||||
|
self.assertEqual(clusters[1]["suggested_plant"]["plant_name"], "Tomato")
|
||||||
|
self.assertEqual(clusters[0]["candidate_plants"][0]["score"], 120.0)
|
||||||
|
self.assertEqual(clusters[1]["candidate_plants"][0]["score"], 150.0)
|
||||||
|
self.assertEqual(clusters[0]["cluster_block"]["uuid"], str(self.cluster_0.uuid))
|
||||||
|
self.assertEqual(clusters[1]["cluster_block"]["uuid"], str(self.cluster_1.uuid))
|
||||||
|
|
||||||
|
def test_cluster_recommendations_return_400_when_no_plants_registered(self):
|
||||||
|
FarmPlantAssignment.objects.all().delete()
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/remote-sensing/cluster-recommendations/",
|
||||||
|
data={"farm_uuid": str(self.farm.farm_uuid)},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(
|
||||||
|
response.json()["msg"],
|
||||||
|
"برای این مزرعه هنوز هیچ گیاهی در farm_data ثبت نشده است.",
|
||||||
|
)
|
||||||
@@ -57,10 +57,29 @@ class SoilDataApiTests(TestCase):
|
|||||||
self.assertEqual(len(payload["block_layout"]["blocks"]), 1)
|
self.assertEqual(len(payload["block_layout"]["blocks"]), 1)
|
||||||
self.assertEqual(payload["block_layout"]["blocks"][0]["boundary"], self.block_boundary)
|
self.assertEqual(payload["block_layout"]["blocks"][0]["boundary"], self.block_boundary)
|
||||||
self.assertEqual(payload["block_layout"]["algorithm_status"], "pending")
|
self.assertEqual(payload["block_layout"]["algorithm_status"], "pending")
|
||||||
|
self.assertEqual(len(payload["block_layout"]["blocks"][0]["sub_blocks"]), 1)
|
||||||
|
self.assertEqual(payload["block_layout"]["blocks"][0]["sub_blocks"][0]["sub_block_code"], "block-1-sub-1")
|
||||||
|
self.assertEqual(payload["block_layout"]["blocks"][0]["sub_blocks"][0]["cluster_label"], 0)
|
||||||
self.assertEqual(len(payload["block_subdivisions"]), 1)
|
self.assertEqual(len(payload["block_subdivisions"]), 1)
|
||||||
self.assertEqual(payload["block_subdivisions"][0]["status"], "defined")
|
self.assertEqual(payload["block_subdivisions"][0]["status"], "defined")
|
||||||
self.assertEqual(payload["satellite_snapshots"][0]["status"], "missing")
|
self.assertEqual(payload["satellite_snapshots"][0]["status"], "missing")
|
||||||
|
|
||||||
|
def test_model_default_layout_includes_default_sub_block_when_missing(self):
|
||||||
|
location = SoilLocation.objects.create(
|
||||||
|
latitude="35.689201",
|
||||||
|
longitude="51.389001",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(location.input_block_count, 1)
|
||||||
|
self.assertEqual(location.block_layout["blocks"][0]["block_code"], "block-1")
|
||||||
|
self.assertEqual(len(location.block_layout["blocks"][0]["sub_blocks"]), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
location.block_layout["blocks"][0]["sub_blocks"][0]["sub_block_code"],
|
||||||
|
"block-1-sub-1",
|
||||||
|
)
|
||||||
|
self.assertEqual(location.block_layout["blocks"][0]["sub_blocks"][0]["source"], "default")
|
||||||
|
|
||||||
|
|
||||||
def test_post_updates_block_layout_from_input(self):
|
def test_post_updates_block_layout_from_input(self):
|
||||||
SoilLocation.objects.create(
|
SoilLocation.objects.create(
|
||||||
latitude="35.689200",
|
latitude="35.689200",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .views import (
|
|||||||
NdviHealthView,
|
NdviHealthView,
|
||||||
RemoteSensingAnalysisView,
|
RemoteSensingAnalysisView,
|
||||||
RemoteSensingClusterBlockLiveView,
|
RemoteSensingClusterBlockLiveView,
|
||||||
|
RemoteSensingClusterRecommendationView,
|
||||||
RemoteSensingSubdivisionOptionActivateView,
|
RemoteSensingSubdivisionOptionActivateView,
|
||||||
RemoteSensingSubdivisionOptionListView,
|
RemoteSensingSubdivisionOptionListView,
|
||||||
RemoteSensingRunStatusView,
|
RemoteSensingRunStatusView,
|
||||||
@@ -18,6 +19,11 @@ urlpatterns = [
|
|||||||
RemoteSensingClusterBlockLiveView.as_view(),
|
RemoteSensingClusterBlockLiveView.as_view(),
|
||||||
name="remote-sensing-cluster-block-live",
|
name="remote-sensing-cluster-block-live",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"remote-sensing/cluster-recommendations/",
|
||||||
|
RemoteSensingClusterRecommendationView.as_view(),
|
||||||
|
name="remote-sensing-cluster-recommendations",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"remote-sensing/results/<int:result_id>/k-options/",
|
"remote-sensing/results/<int:result_id>/k-options/",
|
||||||
RemoteSensingSubdivisionOptionListView.as_view(),
|
RemoteSensingSubdivisionOptionListView.as_view(),
|
||||||
|
|||||||
@@ -37,8 +37,15 @@ from farm_data.models import SensorData
|
|||||||
|
|
||||||
from .data_driven_subdivision import DEFAULT_CLUSTER_FEATURES
|
from .data_driven_subdivision import DEFAULT_CLUSTER_FEATURES
|
||||||
from .data_driven_subdivision import activate_subdivision_option
|
from .data_driven_subdivision import activate_subdivision_option
|
||||||
|
from .cluster_recommendation import (
|
||||||
|
ClusterRecommendationNotFound,
|
||||||
|
ClusterRecommendationValidationError,
|
||||||
|
build_cluster_crop_recommendations,
|
||||||
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
BlockSubdivisionSerializer,
|
BlockSubdivisionSerializer,
|
||||||
|
ClusterCropRecommendationRequestSerializer,
|
||||||
|
ClusterCropRecommendationResponseSerializer,
|
||||||
NdviHealthRequestSerializer,
|
NdviHealthRequestSerializer,
|
||||||
NdviHealthResponseSerializer,
|
NdviHealthResponseSerializer,
|
||||||
RemoteSensingCellObservationSerializer,
|
RemoteSensingCellObservationSerializer,
|
||||||
@@ -140,6 +147,10 @@ RemoteSensingClusterBlockLiveEnvelopeSerializer = build_envelope_serializer(
|
|||||||
"RemoteSensingClusterBlockLiveEnvelopeSerializer",
|
"RemoteSensingClusterBlockLiveEnvelopeSerializer",
|
||||||
RemoteSensingClusterBlockLiveResponseSerializer,
|
RemoteSensingClusterBlockLiveResponseSerializer,
|
||||||
)
|
)
|
||||||
|
ClusterCropRecommendationEnvelopeSerializer = build_envelope_serializer(
|
||||||
|
"ClusterCropRecommendationEnvelopeSerializer",
|
||||||
|
ClusterCropRecommendationResponseSerializer,
|
||||||
|
)
|
||||||
RemoteSensingSubdivisionOptionListEnvelopeSerializer = build_envelope_serializer(
|
RemoteSensingSubdivisionOptionListEnvelopeSerializer = build_envelope_serializer(
|
||||||
"RemoteSensingSubdivisionOptionListEnvelopeSerializer",
|
"RemoteSensingSubdivisionOptionListEnvelopeSerializer",
|
||||||
RemoteSensingSubdivisionOptionListResponseSerializer,
|
RemoteSensingSubdivisionOptionListResponseSerializer,
|
||||||
@@ -843,6 +854,75 @@ class RemoteSensingClusterBlockLiveView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteSensingClusterRecommendationView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Location Data"],
|
||||||
|
summary="پیشنهاد گیاه برای هر کلاستر KMeans",
|
||||||
|
description=(
|
||||||
|
"با دریافت farm_uuid، داده هر کلاستر KMeans location_data بههمراه "
|
||||||
|
"ndvi، ndwi، soil_vv، soil_vv_db و مقایسه گیاههای ثبتشده در farm_data "
|
||||||
|
"با crop_simulation برگردانده میشود و برای هر زیربلاک یک گیاه پیشنهادی ارائه میشود."
|
||||||
|
),
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="farm_uuid",
|
||||||
|
type=str,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=True,
|
||||||
|
description="شناسه یکتای مزرعه",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={
|
||||||
|
200: build_response(
|
||||||
|
ClusterCropRecommendationEnvelopeSerializer,
|
||||||
|
"داده کلاسترها و پیشنهاد گیاه برای هر زیربلاک بازگردانده شد.",
|
||||||
|
),
|
||||||
|
400: build_response(
|
||||||
|
SoilErrorResponseSerializer,
|
||||||
|
"پیشنیازهای مقایسه نامعتبر یا ناقص است.",
|
||||||
|
),
|
||||||
|
404: build_response(
|
||||||
|
SoilErrorResponseSerializer,
|
||||||
|
"مزرعه یا خروجی KMeans یافت نشد.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
"نمونه درخواست پیشنهاد گیاه برای کلاسترها",
|
||||||
|
value={"farm_uuid": "11111111-1111-1111-1111-111111111111"},
|
||||||
|
parameter_only=("farm_uuid", "query"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
serializer = ClusterCropRecommendationRequestSerializer(data=request.query_params)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(
|
||||||
|
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = build_cluster_crop_recommendations(
|
||||||
|
str(serializer.validated_data["farm_uuid"])
|
||||||
|
)
|
||||||
|
except ClusterRecommendationNotFound as exc:
|
||||||
|
return Response(
|
||||||
|
{"code": 404, "msg": str(exc), "data": None},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
except ClusterRecommendationValidationError as exc:
|
||||||
|
return Response(
|
||||||
|
{"code": 400, "msg": str(exc), "data": None},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{"code": 200, "msg": "success", "data": payload},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RemoteSensingSubdivisionOptionListView(APIView):
|
class RemoteSensingSubdivisionOptionListView(APIView):
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Location Data"],
|
tags=["Location Data"],
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
feature_decision(feature) := result if {
|
||||||
|
has_feature_rule(feature)
|
||||||
|
rule := feature_rule(feature)
|
||||||
|
matched := [matched_rule | matched_rule := rule; action_match(matched_rule)]
|
||||||
|
deny_rules := [matched_rule | matched_rule := matched[_]; not object.get(matched_rule, "allow", false)]
|
||||||
|
allow_rules := [matched_rule | matched_rule := matched[_]; object.get(matched_rule, "allow", false)]
|
||||||
|
count(deny_rules) > 0
|
||||||
|
result := {
|
||||||
|
"allow": false,
|
||||||
|
"matched_rules": matched,
|
||||||
|
"deny_rules": deny_rules,
|
||||||
|
"allow_rules": allow_rules,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,9 +130,9 @@ def _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None)
|
|||||||
|
|
||||||
|
|
||||||
def _format_farm_context(farm_uuid: str) -> str:
|
def _format_farm_context(farm_uuid: str) -> str:
|
||||||
from farm_data.services import get_farm_details
|
from farm_data.services import build_ai_farm_snapshot
|
||||||
|
|
||||||
farm_details = get_farm_details(farm_uuid)
|
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||||
if not farm_details:
|
if not farm_details:
|
||||||
raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.")
|
raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.")
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ from typing import Any
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
|
||||||
from farm_data.models import SensorData
|
from farm_data.models import SensorData
|
||||||
from farm_data.services import clone_snapshot_as_runtime_plant, get_farm_plant_snapshot_by_name
|
from farm_data.services import (
|
||||||
|
build_ai_farm_snapshot,
|
||||||
|
clone_snapshot_as_runtime_plant,
|
||||||
|
get_ai_snapshot_weather,
|
||||||
|
get_farm_plant_snapshot_by_name,
|
||||||
|
)
|
||||||
from rag.api_provider import get_chat_client
|
from rag.api_provider import get_chat_client
|
||||||
from rag.chat import (
|
from rag.chat import (
|
||||||
_complete_audit_log,
|
_complete_audit_log,
|
||||||
@@ -628,6 +633,7 @@ def get_fertilization_recommendation(
|
|||||||
.filter(farm_uuid=resolved_farm_uuid)
|
.filter(farm_uuid=resolved_farm_uuid)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
ai_snapshot = build_ai_farm_snapshot(resolved_farm_uuid)
|
||||||
|
|
||||||
plant_config = apps.get_app_config("plant")
|
plant_config = apps.get_app_config("plant")
|
||||||
resolved_plant_name = plant_config.resolve_plant_name(plant_name)
|
resolved_plant_name = plant_config.resolve_plant_name(plant_name)
|
||||||
@@ -662,6 +668,7 @@ def get_fertilization_recommendation(
|
|||||||
plant=plant,
|
plant=plant,
|
||||||
forecasts=forecasts,
|
forecasts=forecasts,
|
||||||
growth_stage=resolved_growth_stage,
|
growth_stage=resolved_growth_stage,
|
||||||
|
ai_snapshot=ai_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
context = build_rag_context(
|
context = build_rag_context(
|
||||||
@@ -727,6 +734,9 @@ def get_fertilization_recommendation(
|
|||||||
growth_stage=resolved_growth_stage,
|
growth_stage=resolved_growth_stage,
|
||||||
forecasts=forecasts,
|
forecasts=forecasts,
|
||||||
)
|
)
|
||||||
|
result.setdefault("source_metadata", {})
|
||||||
|
result["source_metadata"]["farm_metrics"] = (ai_snapshot or {}).get("source_metadata", {}).get("farm_metrics", {})
|
||||||
|
result["source_metadata"]["weather"] = {"source": "center_location_forecast", "policy": "center_location_latest_forecast"}
|
||||||
result = _validate_fertilization_response(result)
|
result = _validate_fertilization_response(result)
|
||||||
result["raw_response"] = raw
|
result["raw_response"] = raw
|
||||||
result["simulation_optimizer"] = optimized_result
|
result["simulation_optimizer"] = optimized_result
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
|
from farm_data.services import build_ai_farm_snapshot
|
||||||
from rag.api_provider import get_chat_client
|
from rag.api_provider import get_chat_client
|
||||||
from rag.chat import (
|
from rag.chat import (
|
||||||
_complete_audit_log,
|
_complete_audit_log,
|
||||||
@@ -124,6 +125,13 @@ class FertilizationPlanParserService:
|
|||||||
"partial_plan": normalized_partial,
|
"partial_plan": normalized_partial,
|
||||||
"required_core_fields": CORE_FIELDS,
|
"required_core_fields": CORE_FIELDS,
|
||||||
"service": "fertilization_plan_parser",
|
"service": "fertilization_plan_parser",
|
||||||
|
"endpoint_policy": "parser_first",
|
||||||
|
}
|
||||||
|
if farm_uuid:
|
||||||
|
# Parser-first endpoint: farm context is optional enrichment only.
|
||||||
|
structured_context["farm_context_source_metadata"] = {
|
||||||
|
"source": "build_ai_farm_snapshot",
|
||||||
|
"optional": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
rag_query = self._build_retrieval_query(
|
rag_query = self._build_retrieval_query(
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ from django.db import transaction
|
|||||||
|
|
||||||
from farm_data.models import SensorData
|
from farm_data.models import SensorData
|
||||||
from farm_data.services import (
|
from farm_data.services import (
|
||||||
|
build_ai_farm_snapshot,
|
||||||
clone_snapshot_as_runtime_plant,
|
clone_snapshot_as_runtime_plant,
|
||||||
|
get_ai_snapshot_metric,
|
||||||
|
get_ai_snapshot_weather,
|
||||||
get_farm_plant_snapshot_by_name,
|
get_farm_plant_snapshot_by_name,
|
||||||
)
|
)
|
||||||
from irrigation.evapotranspiration import (
|
from irrigation.evapotranspiration import (
|
||||||
@@ -55,13 +58,15 @@ def _safe_float(value: Any, default: float = 0.0) -> float:
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _sensor_metric(sensor: SensorData | None, metric: str) -> float | None:
|
def _aggregated_metric(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
|
||||||
if sensor is None or not isinstance(sensor.sensor_payload, dict):
|
value = get_ai_snapshot_metric(ai_snapshot, metric)
|
||||||
return None
|
return _safe_float(value, default=0.0) if value is not None else None
|
||||||
for payload in sensor.sensor_payload.values():
|
|
||||||
if isinstance(payload, dict) and payload.get(metric) is not None:
|
|
||||||
return _safe_float(payload.get(metric), default=0.0)
|
|
||||||
return None
|
def _aggregated_metric_fallback(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
|
||||||
|
"""Limited fallback for missing aggregated metrics only; raw payload is intentionally not consulted."""
|
||||||
|
return _aggregated_metric(ai_snapshot, metric)
|
||||||
|
|
||||||
|
|
||||||
def _coerce_list(value: Any) -> list[Any]:
|
def _coerce_list(value: Any) -> list[Any]:
|
||||||
@@ -275,9 +280,9 @@ def _build_irrigation_ui_payload(
|
|||||||
crop_profile: dict[str, Any],
|
crop_profile: dict[str, Any],
|
||||||
active_kc: float,
|
active_kc: float,
|
||||||
irrigation_method: IrrigationMethod | None,
|
irrigation_method: IrrigationMethod | None,
|
||||||
sensor: SensorData | None,
|
ai_snapshot: dict[str, Any] | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
soil_moisture = _sensor_metric(sensor, "soil_moisture")
|
soil_moisture = _aggregated_metric_fallback(ai_snapshot, "soil_moisture")
|
||||||
plan = _normalize_plan(
|
plan = _normalize_plan(
|
||||||
llm_result,
|
llm_result,
|
||||||
optimizer_result,
|
optimizer_result,
|
||||||
@@ -380,6 +385,7 @@ def get_irrigation_recommendation(
|
|||||||
.filter(farm_uuid=resolved_farm_uuid)
|
.filter(farm_uuid=resolved_farm_uuid)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
ai_snapshot = build_ai_farm_snapshot(resolved_farm_uuid)
|
||||||
irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name)
|
irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name)
|
||||||
_persist_irrigation_method_on_farm(sensor, irrigation_method)
|
_persist_irrigation_method_on_farm(sensor, irrigation_method)
|
||||||
|
|
||||||
@@ -423,6 +429,7 @@ def get_irrigation_recommendation(
|
|||||||
if plant is not None and forecasts:
|
if plant is not None and forecasts:
|
||||||
optimized_result = _get_optimizer().optimize_irrigation(
|
optimized_result = _get_optimizer().optimize_irrigation(
|
||||||
sensor=sensor,
|
sensor=sensor,
|
||||||
|
ai_snapshot=ai_snapshot,
|
||||||
plant=plant,
|
plant=plant,
|
||||||
forecasts=forecasts,
|
forecasts=forecasts,
|
||||||
daily_water_needs=daily_water_needs,
|
daily_water_needs=daily_water_needs,
|
||||||
@@ -518,7 +525,7 @@ def get_irrigation_recommendation(
|
|||||||
crop_profile,
|
crop_profile,
|
||||||
active_kc,
|
active_kc,
|
||||||
irrigation_method,
|
irrigation_method,
|
||||||
sensor,
|
ai_snapshot,
|
||||||
)
|
)
|
||||||
result["raw_response"] = raw
|
result["raw_response"] = raw
|
||||||
result["simulation_optimizer"] = optimized_result
|
result["simulation_optimizer"] = optimized_result
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
|
from farm_data.services import build_ai_farm_snapshot
|
||||||
from rag.api_provider import get_chat_client
|
from rag.api_provider import get_chat_client
|
||||||
from rag.chat import (
|
from rag.chat import (
|
||||||
_complete_audit_log,
|
_complete_audit_log,
|
||||||
@@ -120,6 +121,13 @@ class IrrigationPlanParserService:
|
|||||||
"partial_plan": normalized_partial,
|
"partial_plan": normalized_partial,
|
||||||
"required_core_fields": CORE_FIELDS,
|
"required_core_fields": CORE_FIELDS,
|
||||||
"service": "irrigation_plan_parser",
|
"service": "irrigation_plan_parser",
|
||||||
|
"endpoint_policy": "parser_first",
|
||||||
|
}
|
||||||
|
if farm_uuid:
|
||||||
|
# Parser-first endpoint: farm context is optional enrichment only.
|
||||||
|
structured_context["farm_context_source_metadata"] = {
|
||||||
|
"source": "build_ai_farm_snapshot",
|
||||||
|
"optional": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
rag_query = self._build_retrieval_query(
|
rag_query = self._build_retrieval_query(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from farm_data.services import get_farm_details
|
from farm_data.services import build_ai_farm_snapshot
|
||||||
from rag.api_provider import get_chat_client
|
from rag.api_provider import get_chat_client
|
||||||
from rag.chat import (
|
from rag.chat import (
|
||||||
_build_content_parts,
|
_build_content_parts,
|
||||||
@@ -106,7 +106,7 @@ def _clean_json(raw: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
||||||
farm_details = get_farm_details(farm_uuid)
|
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||||
if farm_details is None:
|
if farm_details is None:
|
||||||
raise RAGServiceError(
|
raise RAGServiceError(
|
||||||
error_code="farm_not_found",
|
error_code="farm_not_found",
|
||||||
@@ -134,8 +134,8 @@ def _build_service_client(cfg: RAGConfig):
|
|||||||
|
|
||||||
|
|
||||||
def _weather_risk_summary(farm_details: dict[str, Any]) -> dict[str, Any]:
|
def _weather_risk_summary(farm_details: dict[str, Any]) -> dict[str, Any]:
|
||||||
weather = farm_details.get("weather") or {}
|
weather = ((farm_details.get("weather") or {}).get("forecast") or {})
|
||||||
soil = (farm_details.get("soil") or {}).get("resolved_metrics") or {}
|
soil = (farm_details.get("farm_metrics") or {}).get("resolved_metrics") or {}
|
||||||
humidity = _safe_float(weather.get("humidity_mean"), 55.0)
|
humidity = _safe_float(weather.get("humidity_mean"), 55.0)
|
||||||
temp = _safe_float(weather.get("temperature_mean"), 24.0)
|
temp = _safe_float(weather.get("temperature_mean"), 24.0)
|
||||||
rain = _safe_float(weather.get("precipitation"), 0.0)
|
rain = _safe_float(weather.get("precipitation"), 0.0)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from farm_data.services import get_farm_details
|
from farm_data.services import build_ai_farm_snapshot
|
||||||
from rag.api_provider import get_chat_client
|
from rag.api_provider import get_chat_client
|
||||||
from rag.chat import (
|
from rag.chat import (
|
||||||
_complete_audit_log,
|
_complete_audit_log,
|
||||||
@@ -73,7 +73,7 @@ def _clean_json(raw: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
||||||
farm_details = get_farm_details(farm_uuid)
|
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||||
if farm_details is None:
|
if farm_details is None:
|
||||||
raise RAGServiceError(
|
raise RAGServiceError(
|
||||||
error_code="farm_not_found",
|
error_code="farm_not_found",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from farm_data.services import get_farm_details
|
from farm_data.services import build_ai_farm_snapshot, get_ai_snapshot_metric, get_ai_snapshot_weather
|
||||||
from rag.api_provider import get_chat_client
|
from rag.api_provider import get_chat_client
|
||||||
from rag.chat import (
|
from rag.chat import (
|
||||||
_complete_audit_log,
|
_complete_audit_log,
|
||||||
@@ -71,7 +71,7 @@ def _clean_json(raw: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
||||||
farm_details = get_farm_details(farm_uuid)
|
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||||
if farm_details is None:
|
if farm_details is None:
|
||||||
raise RAGServiceError(
|
raise RAGServiceError(
|
||||||
error_code="farm_not_found",
|
error_code="farm_not_found",
|
||||||
@@ -158,6 +158,16 @@ def get_water_need_prediction_insight(
|
|||||||
structured_context = {
|
structured_context = {
|
||||||
"farm_uuid": farm_uuid,
|
"farm_uuid": farm_uuid,
|
||||||
"prediction_payload": prediction_payload,
|
"prediction_payload": prediction_payload,
|
||||||
|
"source_metadata": {
|
||||||
|
"agronomic_metrics": {
|
||||||
|
"source": "build_ai_farm_snapshot",
|
||||||
|
"policy": "cluster_block_farm_aggregated",
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"source": "center_location_forecast",
|
||||||
|
"policy": "center_location_latest_forecast",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
rag_context = build_rag_context(
|
rag_context = build_rag_context(
|
||||||
query=user_query,
|
query=user_query,
|
||||||
@@ -208,4 +218,14 @@ def get_water_need_prediction_insight(
|
|||||||
parsed["source"] = "llm"
|
parsed["source"] = "llm"
|
||||||
parsed["farm_uuid"] = farm_uuid
|
parsed["farm_uuid"] = farm_uuid
|
||||||
parsed["raw_response"] = raw
|
parsed["raw_response"] = raw
|
||||||
|
parsed["source_metadata"] = {
|
||||||
|
"agronomic_metrics": {
|
||||||
|
"source": "build_ai_farm_snapshot",
|
||||||
|
"policy": "cluster_block_farm_aggregated",
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"source": "center_location_forecast",
|
||||||
|
"policy": "center_location_latest_forecast",
|
||||||
|
},
|
||||||
|
}
|
||||||
return parsed
|
return parsed
|
||||||
|
|||||||
@@ -69,3 +69,56 @@ class ChatContextTests(SimpleTestCase):
|
|||||||
|
|
||||||
self.assertIn("[اطلاعات کامل مزرعه]", context)
|
self.assertIn("[اطلاعات کامل مزرعه]", context)
|
||||||
self.assertIn("soil_moisture", context)
|
self.assertIn("soil_moisture", context)
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalFarmContextTests(SimpleTestCase):
|
||||||
|
@patch("rag.chat.build_ai_farm_snapshot", return_value={"farm_uuid": "farm-123", "farm_metrics": {"resolved_metrics": {"soil_moisture": 41.0}}})
|
||||||
|
def test_format_farm_context_uses_canonical_ai_snapshot(self, mock_snapshot):
|
||||||
|
from rag.chat import _format_farm_context
|
||||||
|
|
||||||
|
context = _format_farm_context("farm-123")
|
||||||
|
|
||||||
|
self.assertIn("farm_metrics", context)
|
||||||
|
self.assertIn("soil_moisture", context)
|
||||||
|
mock_snapshot.assert_called_once_with("farm-123")
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalUserSoilTextTests(SimpleTestCase):
|
||||||
|
@patch(
|
||||||
|
"rag.user_data.build_ai_farm_snapshot",
|
||||||
|
return_value={
|
||||||
|
"farm_uuid": "farm-123",
|
||||||
|
"aggregation_policy": {
|
||||||
|
"sensor": "cluster_mean_then_block_mean_then_farm_mean",
|
||||||
|
"satellite": "cluster_mean_then_block_mean_then_farm_mean",
|
||||||
|
"weather": "center_location_latest_forecast",
|
||||||
|
},
|
||||||
|
"farm_metrics": {"resolved_metrics": {"soil_moisture": 42.0, "nitrogen": 18.0}},
|
||||||
|
"block_metrics": [
|
||||||
|
{"block_code": "block-1", "resolved_metrics": {"soil_moisture": 40.0}},
|
||||||
|
{"block_code": "block-2", "resolved_metrics": {"soil_moisture": 44.0}},
|
||||||
|
],
|
||||||
|
"sub_block_metrics": [
|
||||||
|
{"block_code": "block-1", "sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 39.0}},
|
||||||
|
{"block_code": "block-2", "sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 45.0}},
|
||||||
|
],
|
||||||
|
"source_metadata": {
|
||||||
|
"farm_metrics": {
|
||||||
|
"canonical_source": "farmer_block_aggregated_snapshot",
|
||||||
|
"aggregation_strategy": "farmer_block_mean",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_build_user_soil_text_uses_only_canonical_snapshot(self, mock_snapshot):
|
||||||
|
from rag.user_data import build_user_soil_text
|
||||||
|
|
||||||
|
text = build_user_soil_text("farm-123")
|
||||||
|
|
||||||
|
self.assertIn("سیاست تجمیع خاک", text)
|
||||||
|
self.assertIn("خلاصه تجمیعشده مزرعه", text)
|
||||||
|
self.assertIn("بلوک block-1", text)
|
||||||
|
self.assertIn("زیر-بلوک cluster-a", text)
|
||||||
|
self.assertIn("canonical_source: farmer_block_aggregated_snapshot", text)
|
||||||
|
self.assertNotIn("sensor_payload", text)
|
||||||
|
mock_snapshot.assert_called_once_with("farm-123")
|
||||||
|
|||||||
@@ -86,3 +86,39 @@ class RAGFailureContractTests(SimpleTestCase):
|
|||||||
YieldHarvestRAGService().generate_narrative({"farm_uuid": "farm-1"})
|
YieldHarvestRAGService().generate_narrative({"farm_uuid": "farm-1"})
|
||||||
|
|
||||||
self.assertEqual(exc_info.exception.contract.error_code, "invalid_json")
|
self.assertEqual(exc_info.exception.contract.error_code, "invalid_json")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("rag.services.soil_anomaly.build_ai_farm_snapshot", return_value={"farm_uuid": "farm-1"})
|
||||||
|
def test_soil_anomaly_loads_canonical_snapshot(self, mock_snapshot):
|
||||||
|
from rag.services.soil_anomaly import _load_farm_or_error
|
||||||
|
|
||||||
|
payload = _load_farm_or_error("farm-1")
|
||||||
|
|
||||||
|
self.assertEqual(payload["farm_uuid"], "farm-1")
|
||||||
|
mock_snapshot.assert_called_once_with("farm-1")
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"rag.services.pest_disease.build_ai_farm_snapshot",
|
||||||
|
return_value={
|
||||||
|
"farm_uuid": "farm-1",
|
||||||
|
"weather": {"forecast": {"humidity_mean": 75.0, "temperature_mean": 31.0, "precipitation": 3.0}},
|
||||||
|
"farm_metrics": {"resolved_metrics": {"soil_moisture": 66.0, "electrical_conductivity": 2.8, "soil_ph": 7.9}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_pest_risk_context_reads_canonical_snapshot_shape(self, mock_snapshot):
|
||||||
|
from rag.services.pest_disease import _build_risk_context, _load_farm_or_error
|
||||||
|
|
||||||
|
farm_details = _load_farm_or_error("farm-1")
|
||||||
|
risk = _build_risk_context(farm_details, plant_name=None, growth_stage=None)
|
||||||
|
|
||||||
|
self.assertEqual(risk["overall_risk"], "high")
|
||||||
|
self.assertIn("EC بالا", risk["key_drivers"])
|
||||||
|
mock_snapshot.assert_called_once_with("farm-1")
|
||||||
|
|
||||||
|
@patch("rag.services.pest_disease.build_ai_farm_snapshot", return_value={"farm_uuid": "farm-1"})
|
||||||
|
def test_pest_detection_remains_image_first_with_optional_farm_context(self, mock_snapshot):
|
||||||
|
with self.assertRaises(RAGServiceError) as exc_info:
|
||||||
|
get_pest_disease_detection(farm_uuid="farm-1", images=[])
|
||||||
|
|
||||||
|
self.assertEqual(exc_info.exception.contract.error_code, "missing_images")
|
||||||
|
mock_snapshot.assert_not_called()
|
||||||
|
|||||||
@@ -385,3 +385,47 @@ class RecommendationServiceDefaultsTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
|
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
|
||||||
self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0)
|
self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0)
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationCanonicalSnapshotTests(TestCase):
|
||||||
|
@patch("rag.services.irrigation.get_chat_client")
|
||||||
|
@patch("rag.services.irrigation.build_rag_context", return_value="")
|
||||||
|
@patch("rag.services.irrigation.build_ai_farm_snapshot")
|
||||||
|
def test_irrigation_ui_payload_uses_aggregated_snapshot_metrics(self, mock_snapshot, _mock_context, mock_client):
|
||||||
|
from rag.services.irrigation import _build_irrigation_ui_payload
|
||||||
|
|
||||||
|
mock_snapshot.return_value = {"farm_metrics": {"resolved_metrics": {"soil_moisture": 44.0}}}
|
||||||
|
payload = _build_irrigation_ui_payload(
|
||||||
|
llm_result={"plan": {}, "timeline": [], "sections": []},
|
||||||
|
optimizer_result=None,
|
||||||
|
daily_water_needs=[],
|
||||||
|
crop_profile={},
|
||||||
|
active_kc=0.9,
|
||||||
|
irrigation_method=None,
|
||||||
|
ai_snapshot=mock_snapshot.return_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(payload["plan"]["moistureLevel"], 44)
|
||||||
|
|
||||||
|
@patch("rag.services.fertilization._get_optimizer")
|
||||||
|
@patch("rag.services.fertilization.get_chat_client")
|
||||||
|
@patch("rag.services.fertilization.build_rag_context", return_value="")
|
||||||
|
@patch("rag.services.fertilization.build_ai_farm_snapshot")
|
||||||
|
def test_fertilization_recommendation_includes_snapshot_provenance(self, mock_snapshot, _mock_context, mock_client, mock_optimizer):
|
||||||
|
from rag.services.fertilization import get_fertilization_recommendation
|
||||||
|
|
||||||
|
client = mock_client.return_value
|
||||||
|
client.chat.completions.create.return_value = type("Resp", (), {"choices": [type("Choice", (), {"message": type("Msg", (), {"content": '{"status": "success", "data": {}}'})()})()]})()
|
||||||
|
mock_snapshot.return_value = {
|
||||||
|
"source_metadata": {
|
||||||
|
"farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_optimizer.return_value.optimize_fertilization.return_value = None
|
||||||
|
|
||||||
|
with patch("rag.services.fertilization.SensorData.objects.select_related") as mock_select:
|
||||||
|
mock_select.return_value.prefetch_related.return_value.filter.return_value.first.return_value = None
|
||||||
|
result = get_fertilization_recommendation(farm_uuid="farm-1")
|
||||||
|
|
||||||
|
self.assertEqual(result["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||||
|
self.assertEqual(result["source_metadata"]["weather"]["policy"], "center_location_latest_forecast")
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ class ChatView(APIView):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def post(self, request: Request):
|
def post(self, request: Request):
|
||||||
from farm_data.services import get_farm_details
|
from farm_data.services import build_ai_farm_snapshot
|
||||||
from .config import load_rag_config
|
from .config import load_rag_config
|
||||||
|
|
||||||
data = request.data if request.method == "POST" else request.query_params
|
data = request.data if request.method == "POST" else request.query_params
|
||||||
@@ -178,7 +178,7 @@ class ChatView(APIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
cfg = load_rag_config()
|
cfg = load_rag_config()
|
||||||
farm_details = get_farm_details(farm_uuid)
|
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||||
if farm_details is None:
|
if farm_details is None:
|
||||||
return Response(
|
return Response(
|
||||||
{"code": 404, "msg": "farm پیدا نشد."},
|
{"code": 404, "msg": "farm پیدا نشد."},
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import MySQLdb
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
|
TABLES = [
|
||||||
|
"location_data_soillocation",
|
||||||
|
"location_data_blocksubdivision",
|
||||||
|
"location_data_remotesensingrun",
|
||||||
|
"location_data_remotesensingsubdivisionresult",
|
||||||
|
"location_data_remotesensingclusterblock",
|
||||||
|
"location_data_remotesensingclusterassignment",
|
||||||
|
"location_data_analysisgridcell",
|
||||||
|
"location_data_analysisgridobservation",
|
||||||
|
"location_data_remotesensingsubdivisionoption",
|
||||||
|
"location_data_remotesensingsubdivisionoptionblock",
|
||||||
|
"location_data_remotesensingsubdivisionoptionassignment",
|
||||||
|
"dashboard_data_ndviobservation",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
env = dotenv_values(Path(__file__).resolve().parent.parent / ".env")
|
||||||
|
conn = MySQLdb.connect(
|
||||||
|
host=env.get("DB_HOST", "127.0.0.1"),
|
||||||
|
port=int(env.get("DB_PORT", 3306)),
|
||||||
|
user=env.get("DB_USER", ""),
|
||||||
|
passwd=env.get("DB_PASSWORD", ""),
|
||||||
|
db=env.get("DB_NAME", ""),
|
||||||
|
charset="utf8mb4",
|
||||||
|
)
|
||||||
|
out: dict[str, list[dict]] = {}
|
||||||
|
try:
|
||||||
|
with conn as cursor:
|
||||||
|
for table in TABLES:
|
||||||
|
cursor.execute(f"SELECT * FROM {table}")
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
rows = []
|
||||||
|
for raw_row in cursor.fetchall():
|
||||||
|
row = {}
|
||||||
|
for key, value in zip(columns, raw_row):
|
||||||
|
if isinstance(value, (bytes, bytearray)):
|
||||||
|
value = value.decode("utf-8")
|
||||||
|
row[key] = value
|
||||||
|
rows.append(row)
|
||||||
|
out[table] = rows
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -281,6 +281,8 @@ def _heatmap_summary(sensor_points: list[dict[str, Any]], grid_cells: list[dict[
|
|||||||
|
|
||||||
class SoilMoistureHeatmapService:
|
class SoilMoistureHeatmapService:
|
||||||
def get_heatmap(self, *, farm_uuid: str) -> dict[str, Any]:
|
def get_heatmap(self, *, farm_uuid: str) -> dict[str, Any]:
|
||||||
|
# Spatial-first endpoint: preserve grid/cluster-aware output and never flatten the
|
||||||
|
# heatmap to a single farm-average metric. Aggregated summaries are supplementary only.
|
||||||
current_sensor = (
|
current_sensor = (
|
||||||
SensorData.objects.select_related("center_location")
|
SensorData.objects.select_related("center_location")
|
||||||
.prefetch_related("plant_assignments__plant")
|
.prefetch_related("plant_assignments__plant")
|
||||||
@@ -329,6 +331,11 @@ class SoilMoistureHeatmapService:
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"summary": _heatmap_summary(sensor_points, []),
|
"summary": _heatmap_summary(sensor_points, []),
|
||||||
|
"source_metadata": {
|
||||||
|
"endpoint_policy": "spatial_first",
|
||||||
|
"primary_source": "sensor_network_spatial_interpolation",
|
||||||
|
"agronomic_aggregation": "supplementary_only",
|
||||||
|
},
|
||||||
"quality_legend": {
|
"quality_legend": {
|
||||||
QUALITY_REAL: "اندازه گیری واقعی سنسور",
|
QUALITY_REAL: "اندازه گیری واقعی سنسور",
|
||||||
QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی",
|
QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی",
|
||||||
@@ -438,6 +445,11 @@ class SoilMoistureHeatmapService:
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"summary": _heatmap_summary(sensor_points, [cell for cell in grid_cells if cell["inside_farm_boundary"]]),
|
"summary": _heatmap_summary(sensor_points, [cell for cell in grid_cells if cell["inside_farm_boundary"]]),
|
||||||
|
"source_metadata": {
|
||||||
|
"endpoint_policy": "spatial_first",
|
||||||
|
"primary_source": "sensor_network_spatial_interpolation",
|
||||||
|
"agronomic_aggregation": "supplementary_only",
|
||||||
|
},
|
||||||
"quality_legend": {
|
"quality_legend": {
|
||||||
QUALITY_REAL: "اندازه گیری واقعی سنسور",
|
QUALITY_REAL: "اندازه گیری واقعی سنسور",
|
||||||
QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی",
|
QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from farm_data.models import SensorData
|
from farm_data.models import SensorData
|
||||||
|
from farm_data.services import build_ai_farm_snapshot
|
||||||
|
|
||||||
from .services import get_forecast_for_location
|
from .services import get_forecast_for_location
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ def _weather_condition(weather_code):
|
|||||||
return WMO_CONDITIONS.get(weather_code, "نامشخص")
|
return WMO_CONDITIONS.get(weather_code, "نامشخص")
|
||||||
|
|
||||||
|
|
||||||
def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]:
|
def _build_farm_weather_card(forecasts: list[Any], *, farm_uuid: str | None = None, ai_snapshot: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
if not forecasts:
|
if not forecasts:
|
||||||
return {
|
return {
|
||||||
"condition": "نامشخص",
|
"condition": "نامشخص",
|
||||||
@@ -49,6 +50,10 @@ def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]:
|
|||||||
"windSpeed": 0,
|
"windSpeed": 0,
|
||||||
"windUnit": "km/h",
|
"windUnit": "km/h",
|
||||||
"chartData": {"labels": [], "series": [[]]},
|
"chartData": {"labels": [], "series": [[]]},
|
||||||
|
"source_metadata": {
|
||||||
|
"weather": {"source": "center_location_forecast", "policy": "center_location_latest_forecast"},
|
||||||
|
"agronomic_metrics": {"source": "build_ai_farm_snapshot", "policy": "cluster_block_farm_aggregated"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
current_forecast = forecasts[0]
|
current_forecast = forecasts[0]
|
||||||
@@ -66,6 +71,14 @@ def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]:
|
|||||||
"labels": labels,
|
"labels": labels,
|
||||||
"series": series,
|
"series": series,
|
||||||
},
|
},
|
||||||
|
"source_metadata": {
|
||||||
|
"weather": {"source": "center_location_forecast", "policy": "center_location_latest_forecast"},
|
||||||
|
"agronomic_metrics": {
|
||||||
|
"source": "build_ai_farm_snapshot",
|
||||||
|
"policy": "cluster_block_farm_aggregated",
|
||||||
|
"available": bool(ai_snapshot),
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -80,4 +93,5 @@ class FarmWeatherService:
|
|||||||
raise ValueError("Farm not found.")
|
raise ValueError("Farm not found.")
|
||||||
|
|
||||||
forecasts = get_forecast_for_location(sensor.center_location, days=7)
|
forecasts = get_forecast_for_location(sensor.center_location, days=7)
|
||||||
return _build_farm_weather_card(forecasts)
|
ai_snapshot = build_ai_farm_snapshot(farm_uuid)
|
||||||
|
return _build_farm_weather_card(forecasts, farm_uuid=farm_uuid, ai_snapshot=ai_snapshot)
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ class FarmWeatherApiTests(TestCase):
|
|||||||
"labels": ["2026-04-01", "2026-04-02"],
|
"labels": ["2026-04-01", "2026-04-02"],
|
||||||
"series": [[28.0, 29.0]],
|
"series": [[28.0, 29.0]],
|
||||||
},
|
},
|
||||||
|
"source_metadata": {
|
||||||
|
"weather": {"source": "center_location_forecast", "policy": "center_location_latest_forecast"},
|
||||||
|
"agronomic_metrics": {"source": "build_ai_farm_snapshot", "policy": "cluster_block_farm_aggregated", "available": True},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mock_get_app_config.return_value = SimpleNamespace(
|
mock_get_app_config.return_value = SimpleNamespace(
|
||||||
@@ -44,6 +48,7 @@ class FarmWeatherApiTests(TestCase):
|
|||||||
payload = response.json()["data"]
|
payload = response.json()["data"]
|
||||||
self.assertEqual(payload["condition"], "صاف")
|
self.assertEqual(payload["condition"], "صاف")
|
||||||
self.assertEqual(payload["chartData"]["labels"][0], "2026-04-01")
|
self.assertEqual(payload["chartData"]["labels"][0], "2026-04-01")
|
||||||
|
self.assertEqual(payload["source_metadata"]["weather"]["policy"], "center_location_latest_forecast")
|
||||||
|
|
||||||
@patch("weather.views.apps.get_app_config")
|
@patch("weather.views.apps.get_app_config")
|
||||||
def test_farm_weather_api_returns_404_for_missing_farm(self, mock_get_app_config):
|
def test_farm_weather_api_returns_404_for_missing_farm(self, mock_get_app_config):
|
||||||
@@ -106,6 +111,8 @@ class WaterNeedPredictionApiTests(TestCase):
|
|||||||
payload = response.json()["data"]
|
payload = response.json()["data"]
|
||||||
self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000")
|
self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000")
|
||||||
self.assertEqual(payload["insight"]["confidence"], 0.82)
|
self.assertEqual(payload["insight"]["confidence"], 0.82)
|
||||||
|
self.assertEqual(payload["source_metadata"]["agronomic_metrics"]["policy"], "cluster_block_farm_aggregated")
|
||||||
|
self.assertEqual(payload["source_metadata"]["weather"]["policy"], "center_location_latest_forecast")
|
||||||
|
|
||||||
@patch("weather.views.apps.get_app_config")
|
@patch("weather.views.apps.get_app_config")
|
||||||
def test_water_need_api_returns_404_for_missing_farm(self, mock_get_app_config):
|
def test_water_need_api_returns_404_for_missing_farm(self, mock_get_app_config):
|
||||||
@@ -150,3 +157,8 @@ class WaterNeedPredictionApiTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 502)
|
self.assertEqual(response.status_code, 502)
|
||||||
self.assertEqual(response.json()["data"]["error_code"], "invalid_json")
|
self.assertEqual(response.json()["data"]["error_code"], "invalid_json")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROOT_URLCONF="soile.urls")
|
||||||
|
class SpatialPolicySmokeTests(TestCase):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from farm_data.services import clone_snapshot_as_runtime_plant, get_primary_plant_snapshot
|
from farm_data.services import build_ai_farm_snapshot, clone_snapshot_as_runtime_plant, get_primary_plant_snapshot, get_ai_snapshot_weather
|
||||||
from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile
|
from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile
|
||||||
|
|
||||||
from farm_data.models import SensorData
|
from farm_data.models import SensorData
|
||||||
@@ -11,7 +11,7 @@ from rag.services import get_water_need_prediction_insight
|
|||||||
from .services import get_forecast_for_location
|
from .services import get_forecast_for_location
|
||||||
|
|
||||||
|
|
||||||
def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any]) -> dict[str, Any]:
|
def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any], ai_snapshot: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
location = getattr(sensor, "center_location", None)
|
location = getattr(sensor, "center_location", None)
|
||||||
plant = clone_snapshot_as_runtime_plant(get_primary_plant_snapshot(sensor))
|
plant = clone_snapshot_as_runtime_plant(get_primary_plant_snapshot(sensor))
|
||||||
irrigation_method = getattr(sensor, "irrigation_method", None)
|
irrigation_method = getattr(sensor, "irrigation_method", None)
|
||||||
@@ -46,6 +46,17 @@ def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any]) ->
|
|||||||
"dailyBreakdown": daily,
|
"dailyBreakdown": daily,
|
||||||
"cropProfile": crop_profile,
|
"cropProfile": crop_profile,
|
||||||
"irrigationEfficiencyPercent": efficiency,
|
"irrigationEfficiencyPercent": efficiency,
|
||||||
|
"source_metadata": {
|
||||||
|
"agronomic_metrics": {
|
||||||
|
"source": "build_ai_farm_snapshot",
|
||||||
|
"policy": "cluster_block_farm_aggregated",
|
||||||
|
"available": bool(ai_snapshot),
|
||||||
|
},
|
||||||
|
"weather": {
|
||||||
|
"source": "center_location_forecast",
|
||||||
|
"policy": "center_location_latest_forecast",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -61,7 +72,8 @@ class WaterNeedPredictionService:
|
|||||||
raise ValueError("Farm not found.")
|
raise ValueError("Farm not found.")
|
||||||
|
|
||||||
forecasts = get_forecast_for_location(sensor.center_location, days=7)
|
forecasts = get_forecast_for_location(sensor.center_location, days=7)
|
||||||
payload = build_water_need_prediction_payload(sensor=sensor, forecasts=forecasts)
|
ai_snapshot = build_ai_farm_snapshot(farm_uuid)
|
||||||
|
payload = build_water_need_prediction_payload(sensor=sensor, forecasts=forecasts, ai_snapshot=ai_snapshot)
|
||||||
insight = get_water_need_prediction_insight(
|
insight = get_water_need_prediction_insight(
|
||||||
farm_uuid=farm_uuid,
|
farm_uuid=farm_uuid,
|
||||||
prediction_payload=payload,
|
prediction_payload=payload,
|
||||||
|
|||||||