This commit is contained in:
2026-05-13 16:45:54 +03:30
parent 948c062b93
commit 46fe62fa04
96 changed files with 3834 additions and 155 deletions
+17 -26
View File
@@ -6,7 +6,7 @@ from statistics import mean
from typing import Any
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
@@ -47,20 +47,9 @@ def _first_not_none(*values: Any) -> Any:
return None
def _sensor_metric(sensor: Any, metric: str) -> float | None:
if sensor is None:
return None
if hasattr(sensor, metric):
value = getattr(sensor, metric)
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 _aggregated_metric(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None:
value = get_ai_snapshot_metric(ai_snapshot, metric)
return _safe_float(value, default=0.0) if value is not None else None
def _parse_temperature_range(plant: Any) -> tuple[float, float]:
@@ -140,15 +129,9 @@ def _build_weather_records(forecasts: list[Any], *, latitude: float, longitude:
return records
def _build_soil_parameters(sensor: Any) -> tuple[dict[str, Any], dict[str, Any]]:
moisture_pct = _sensor_metric(sensor, "soil_moisture")
center_location = getattr(sensor, "center_location", None)
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)
def _build_soil_parameters(sensor: Any, ai_snapshot: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
moisture_pct = _aggregated_metric(ai_snapshot, "soil_moisture")
ndwi = _safe_float(_aggregated_metric(ai_snapshot, "ndwi"), 0.34)
wv0033 = ndwi if ndwi > 0 else 0.34
wv1500 = max(round(wv0033 * 0.45, 3), 0.14)
@@ -288,6 +271,7 @@ class SimulationRecommendationOptimizer:
daily_water_needs: list[dict[str, Any]],
growth_stage: str | None,
irrigation_method: Any | None,
ai_snapshot: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
if sensor is None or plant is None or not forecasts:
return None
@@ -301,6 +285,7 @@ class SimulationRecommendationOptimizer:
daily_water_needs=daily_water_needs,
growth_stage=growth_stage,
crop_blueprint=crop_blueprint,
ai_snapshot=ai_snapshot,
)
if pcse_result is not None:
return pcse_result
@@ -312,6 +297,7 @@ class SimulationRecommendationOptimizer:
daily_water_needs=daily_water_needs,
growth_stage=growth_stage,
irrigation_method=irrigation_method,
ai_snapshot=ai_snapshot,
)
def optimize_fertilization(
@@ -321,6 +307,7 @@ class SimulationRecommendationOptimizer:
plant: Any,
forecasts: list[Any],
growth_stage: str | None,
ai_snapshot: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
if sensor is None or plant is None:
return None
@@ -333,6 +320,7 @@ class SimulationRecommendationOptimizer:
forecasts=forecasts,
growth_stage=growth_stage,
crop_blueprint=crop_blueprint,
ai_snapshot=ai_snapshot,
)
if pcse_result is not None:
return pcse_result
@@ -342,6 +330,7 @@ class SimulationRecommendationOptimizer:
plant=plant,
forecasts=forecasts,
growth_stage=growth_stage,
ai_snapshot=ai_snapshot,
)
def _optimize_irrigation_with_pcse(
@@ -353,10 +342,11 @@ class SimulationRecommendationOptimizer:
daily_water_needs: list[dict[str, Any]],
growth_stage: str | None,
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
ai_snapshot: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
defaults = apps.get_app_config("irrigation").get_optimizer_defaults()
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(
forecasts,
latitude=_safe_float(sensor.center_location.latitude),
@@ -572,10 +562,11 @@ class SimulationRecommendationOptimizer:
forecasts: list[Any],
growth_stage: str | None,
crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]],
ai_snapshot: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
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(
forecasts,
latitude=_safe_float(sensor.center_location.latitude),
+36 -24
View File
@@ -7,7 +7,8 @@ from datetime import date, datetime, timedelta
from typing import Any
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
@@ -303,23 +304,22 @@ def _clamp(value: float, lower: float, upper: float) -> float:
return max(lower, min(value, upper))
def _sensor_metric(sensor: Any, metric_name: str) -> float | None:
if sensor is None:
def _snapshot_metric(snapshot: dict[str, Any] | None, metric_name: str) -> float | None:
if not isinstance(snapshot, dict):
return None
if hasattr(sensor, metric_name):
value = getattr(sensor, metric_name)
if value is not None:
return _safe_float(value)
payload = getattr(sensor, "sensor_payload", None) or {}
if not isinstance(payload, dict):
farm_metrics = 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_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))
return None
def _snapshot_source_metadata(snapshot: dict[str, Any] | None) -> dict[str, Any]:
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:
@@ -458,6 +458,9 @@ def build_simulation_payload_from_farm(
raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.")
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:
resolved_weather = _normalize_weather_records(weather)
@@ -476,8 +479,7 @@ def build_simulation_payload_from_farm(
longitude=float(farm.center_location.longitude),
)
satellite_metrics = build_location_satellite_snapshot(farm.center_location).get("resolved_metrics") or {}
ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.28)
ndwi = _snapshot_metric(ai_snapshot, "ndwi")
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))
sm0 = _clamp(
@@ -485,17 +487,17 @@ def build_simulation_payload_from_farm(
max(smfcf + 0.02, smw + 0.05),
0.8,
)
soil_moisture = _sensor_metric(farm, "soil_moisture")
soil_moisture = _snapshot_metric(ai_snapshot, "soil_moisture")
wav = (
round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3)
if soil_moisture is not None
else DEFAULT_WAV
)
nitrogen = _pick_first_not_none(_sensor_metric(farm, "nitrogen"), satellite_metrics.get("soil_vv_db"))
phosphorus = _sensor_metric(farm, "phosphorus")
potassium = _sensor_metric(farm, "potassium")
soil_ph = _pick_first_not_none(_sensor_metric(farm, "soil_ph"), None)
ec = _sensor_metric(farm, "electrical_conductivity")
nitrogen = _pick_first_not_none(_snapshot_metric(ai_snapshot, "nitrogen"), _snapshot_metric(ai_snapshot, "soil_vv_db"))
phosphorus = _snapshot_metric(ai_snapshot, "phosphorus")
potassium = _snapshot_metric(ai_snapshot, "potassium")
soil_ph = _pick_first_not_none(_snapshot_metric(ai_snapshot, "soil_ph"), None)
ec = _snapshot_metric(ai_snapshot, "electrical_conductivity")
resolved_soil = {
"SMFCF": round(smfcf, 3),
@@ -569,6 +571,13 @@ def build_simulation_payload_from_farm(
"site_parameters": resolved_site,
"crop_parameters": resolved_crop,
"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 {},
"farm_uuid": farm_uuid,
"plant_name": plant_name,
"source_metadata": resolved.get("source_metadata") or {},
}
),
)
@@ -937,6 +947,7 @@ class CropSimulationService:
"crop_parameters": resolved["crop_parameters"],
"site_parameters": resolved["site_parameters"],
"agromanagement": merged_agromanagement,
"source_metadata": resolved.get("source_metadata") or {},
}
],
)
@@ -1205,6 +1216,7 @@ class CropSimulationService:
"crop_parameters": resolved["crop_parameters"],
"site_parameters": resolved["site_parameters"],
"agromanagement": merged_agromanagement,
"source_metadata": resolved.get("source_metadata") or {},
}
)
return self._execute_scenario(scenario=scenario, run_specs=run_specs)
@@ -1258,7 +1270,7 @@ class CropSimulationService:
site_parameters=spec["site_parameters"],
)
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"])
results.append(
{
+98
View File
@@ -11,6 +11,10 @@ from django.test import TestCase
from rest_framework.test import APIRequestFactory
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 .views import PlantGrowthSimulationView
@@ -366,3 +370,97 @@ class CropSimulationPcseIntegrationTests(TestCase):
self.assertEqual(result["result"]["engine"], "pcse")
self.assertIsNotNone(result["result"]["metrics"]["yield_estimate"])
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")
+11 -7
View File
@@ -10,7 +10,7 @@ from django.apps import apps
from django.conf import settings
from farm_data.models import SensorData
from farm_data.services import get_farm_details
from farm_data.services import build_ai_farm_snapshot
from location_data.models import NdviObservation, SoilLocation
from rag.failure_contract import RAGServiceError
@@ -720,13 +720,13 @@ class YieldHarvestSummaryService:
"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
soil_details = (farm_details.get("soil") or {}).get("resolved_metrics") or {}
weather_details = farm_details.get("weather") or {}
soil_details = (farm_details.get("farm_metrics") or {}).get("resolved_metrics") or {}
weather_details = ((farm_details.get("weather") or {}).get("forecast") or {})
recent_sensor_averages = {
"soil_moisture": self._safe_float(soil_details.get("soil_moisture", farm.soil_moisture), None),
"soil_temperature": self._safe_float(soil_details.get("soil_temperature", farm.soil_temperature), None),
"soil_moisture": self._safe_float(soil_details.get("soil_moisture"), None),
"soil_temperature": self._safe_float(soil_details.get("soil_temperature"), None),
"air_temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None),
}
@@ -744,7 +744,7 @@ class YieldHarvestSummaryService:
"lat": float(center_location.latitude),
"lon": float(center_location.longitude),
},
"farm_boundary": farm_details.get("center_location", {}).get("farm_boundary"),
"farm_boundary": getattr(center_location, "farm_boundary", None),
"soil": {
"provider": getattr(settings, "SOIL_DATA_PROVIDER", "نامشخص"),
"soil_type": self._infer_soil_type(soil_details),
@@ -760,6 +760,10 @@ class YieldHarvestSummaryService:
"sensor_data": SensorData.__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: