UPDATE
This commit is contained in:
+518
-72
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
@@ -13,6 +14,9 @@ from .models import SimulationRun, SimulationScenario
|
||||
DEFAULT_OUTPUT_VARS = ["DVS", "LAI", "TAGP", "TWSO", "SM"]
|
||||
DEFAULT_SUMMARY_VARS = ["TAGP", "TWSO", "CTRAT", "RD"]
|
||||
DEFAULT_TERMINAL_VARS = ["TAGP", "TWSO", "LAI", "DVS"]
|
||||
DEFAULT_PCSE_MODEL_NAME = "Wofost81_NWLP_CWB_CNB"
|
||||
DEFAULT_NAVAILI = 35.0
|
||||
DEFAULT_WAV = 40.0
|
||||
|
||||
|
||||
class CropSimulationError(Exception):
|
||||
@@ -229,6 +233,265 @@ def _pick_first_not_none(*values: Any) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value in (None, ""):
|
||||
return default
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _clamp(value: float, lower: float, upper: float) -> float:
|
||||
return max(lower, min(value, upper))
|
||||
|
||||
|
||||
def _sensor_metric(sensor: Any, metric_name: str) -> float | None:
|
||||
if sensor is None:
|
||||
return None
|
||||
|
||||
if hasattr(sensor, metric_name):
|
||||
value = getattr(sensor, metric_name)
|
||||
if value is not None:
|
||||
return _safe_float(value)
|
||||
|
||||
payload = getattr(sensor, "sensor_payload", None) or {}
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
for block in payload.values():
|
||||
if isinstance(block, dict) and block.get(metric_name) is not None:
|
||||
return _safe_float(block.get(metric_name))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_plant_simulation_profile(plant: Any | None) -> dict[str, Any] | None:
|
||||
if plant is None:
|
||||
return None
|
||||
|
||||
for attr in ("growth_profile", "irrigation_profile", "health_profile"):
|
||||
profile = getattr(plant, attr, None) or {}
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
simulation = profile.get("simulation")
|
||||
if isinstance(simulation, dict):
|
||||
return simulation
|
||||
return None
|
||||
|
||||
|
||||
def _build_default_crop_parameters(plant: Any | None, crop_name: str) -> dict[str, Any]:
|
||||
profile = getattr(plant, "growth_profile", None) or {}
|
||||
required_gdd = _safe_float(profile.get("required_gdd_for_maturity"), 1200.0)
|
||||
return {
|
||||
"crop_name": crop_name,
|
||||
"TSUM1": round(required_gdd * 0.45, 3),
|
||||
"TSUM2": round(required_gdd * 0.55, 3),
|
||||
"YIELD_SCALE": 1.0,
|
||||
"MAX_LAI": 5.0,
|
||||
"MAX_BIOMASS": 12000.0,
|
||||
}
|
||||
|
||||
|
||||
def _build_default_agromanagement(crop_name: str, weather: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
first_day = weather[0]["DAY"]
|
||||
last_day = weather[-1]["DAY"]
|
||||
crop_end = max(last_day, first_day + (last_day - first_day))
|
||||
return [
|
||||
{
|
||||
first_day: {
|
||||
"CropCalendar": {
|
||||
"crop_name": crop_name,
|
||||
"variety_name": "default",
|
||||
"crop_start_date": first_day,
|
||||
"crop_start_type": "sowing",
|
||||
"crop_end_date": crop_end,
|
||||
"crop_end_type": "harvest",
|
||||
"max_duration": max((crop_end - first_day).days, 1),
|
||||
},
|
||||
"TimedEvents": [],
|
||||
"StateEvents": [],
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _build_weather_from_forecasts(forecasts: list[Any], *, latitude: float, longitude: float) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"DAY": forecast.forecast_date,
|
||||
"LAT": latitude,
|
||||
"LON": longitude,
|
||||
"ELEV": 1200.0,
|
||||
"IRRAD": 16_000_000.0,
|
||||
"TMIN": _safe_float(
|
||||
_pick_first_not_none(forecast.temperature_min, forecast.temperature_mean),
|
||||
12.0,
|
||||
),
|
||||
"TMAX": _safe_float(
|
||||
_pick_first_not_none(forecast.temperature_max, forecast.temperature_mean),
|
||||
24.0,
|
||||
),
|
||||
"VAP": max(_safe_float(forecast.humidity_mean, 55.0) / 5.0, 6.0),
|
||||
"WIND": _safe_float(forecast.wind_speed_max, 7.2) / 3.6,
|
||||
"RAIN": _safe_float(forecast.precipitation, 0.0),
|
||||
"E0": _safe_float(forecast.et0, 0.35),
|
||||
"ES0": max(_safe_float(forecast.et0, 0.35) * 0.9, 0.1),
|
||||
"ET0": _safe_float(forecast.et0, 0.35),
|
||||
}
|
||||
for forecast in forecasts
|
||||
]
|
||||
|
||||
|
||||
def _normalize_site_parameters_for_model(
|
||||
model_name: str,
|
||||
site_parameters: dict[str, Any] | None,
|
||||
*,
|
||||
soil_parameters: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
site = dict(site_parameters or {})
|
||||
soil = soil_parameters or {}
|
||||
|
||||
site.setdefault("WAV", _safe_float(site.get("WAV"), DEFAULT_WAV))
|
||||
if model_name.startswith("Wofost81_NWLP"):
|
||||
navaili = _pick_first_not_none(
|
||||
site.get("NAVAILI"),
|
||||
site.get("navaili"),
|
||||
site.get("nitrogen"),
|
||||
soil.get("NAVAILI"),
|
||||
soil.get("nitrogen"),
|
||||
)
|
||||
site["NAVAILI"] = _safe_float(navaili, DEFAULT_NAVAILI)
|
||||
site.setdefault("BG_N_SUPPLY", 0.05)
|
||||
site.setdefault("NSOILBASE", max(site["NAVAILI"] * 0.35, 5.0))
|
||||
site.setdefault("NSOILBASE_FR", 0.02)
|
||||
return site
|
||||
|
||||
|
||||
def build_simulation_payload_from_farm(
|
||||
*,
|
||||
farm_uuid: str,
|
||||
plant_name: str | None = None,
|
||||
weather: Any | None = None,
|
||||
soil: dict[str, Any] | None = None,
|
||||
crop_parameters: dict[str, Any] | None = None,
|
||||
agromanagement: Any | None = None,
|
||||
site_parameters: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from farm_data.models import SensorData
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
farm = (
|
||||
SensorData.objects.select_related("center_location", "irrigation_method")
|
||||
.prefetch_related("plants", "center_location__depths")
|
||||
.filter(farm_uuid=farm_uuid)
|
||||
.first()
|
||||
)
|
||||
if farm is None:
|
||||
raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.")
|
||||
|
||||
plant = None
|
||||
if plant_name:
|
||||
plant = farm.plants.filter(name=plant_name).first()
|
||||
if plant is None:
|
||||
plant = farm.plants.first()
|
||||
|
||||
if weather is not None:
|
||||
resolved_weather = _normalize_weather_records(weather)
|
||||
else:
|
||||
forecasts = list(
|
||||
WeatherForecast.objects.filter(location=farm.center_location)
|
||||
.order_by("forecast_date")[:14]
|
||||
)
|
||||
if not forecasts:
|
||||
raise CropSimulationError(
|
||||
"Weather data for the selected farm is missing."
|
||||
)
|
||||
resolved_weather = _build_weather_from_forecasts(
|
||||
forecasts,
|
||||
latitude=float(farm.center_location.latitude),
|
||||
longitude=float(farm.center_location.longitude),
|
||||
)
|
||||
|
||||
depths = list(farm.center_location.depths.all())
|
||||
top_depth = depths[0] if depths else None
|
||||
smfcf = _clamp(_safe_float(getattr(top_depth, "wv0033", None), 0.34), 0.2, 0.55)
|
||||
smw = _clamp(_safe_float(getattr(top_depth, "wv1500", None), 0.14), 0.05, max(smfcf - 0.02, 0.06))
|
||||
soil_moisture = _sensor_metric(farm, "soil_moisture")
|
||||
wav = (
|
||||
round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3)
|
||||
if soil_moisture is not None
|
||||
else DEFAULT_WAV
|
||||
)
|
||||
nitrogen = _pick_first_not_none(_sensor_metric(farm, "nitrogen"), getattr(top_depth, "nitrogen", None))
|
||||
phosphorus = _sensor_metric(farm, "phosphorus")
|
||||
potassium = _sensor_metric(farm, "potassium")
|
||||
soil_ph = _pick_first_not_none(_sensor_metric(farm, "soil_ph"), getattr(top_depth, "phh2o", None))
|
||||
ec = _sensor_metric(farm, "electrical_conductivity")
|
||||
|
||||
resolved_soil = {
|
||||
"SMFCF": round(smfcf, 3),
|
||||
"SMW": round(smw, 3),
|
||||
"RDMSOL": 120.0,
|
||||
"soil_moisture": soil_moisture,
|
||||
"nitrogen": _safe_float(nitrogen, DEFAULT_NAVAILI),
|
||||
"phosphorus": _safe_float(phosphorus, 0.0),
|
||||
"potassium": _safe_float(potassium, 0.0),
|
||||
"soil_ph": _safe_float(soil_ph, 7.0),
|
||||
"electrical_conductivity": _safe_float(ec, 0.0),
|
||||
"clay": _safe_float(getattr(top_depth, "clay", None), 0.0),
|
||||
"sand": _safe_float(getattr(top_depth, "sand", None), 0.0),
|
||||
"silt": _safe_float(getattr(top_depth, "silt", None), 0.0),
|
||||
"cec": _safe_float(getattr(top_depth, "cec", None), 0.0),
|
||||
"soc": _safe_float(getattr(top_depth, "soc", None), 0.0),
|
||||
}
|
||||
if soil:
|
||||
resolved_soil.update(soil)
|
||||
|
||||
resolved_site = {
|
||||
"WAV": wav,
|
||||
"NAVAILI": _safe_float(nitrogen, DEFAULT_NAVAILI),
|
||||
"P_STATUS": _safe_float(phosphorus, 0.0),
|
||||
"K_STATUS": _safe_float(potassium, 0.0),
|
||||
"SOIL_PH": _safe_float(soil_ph, 7.0),
|
||||
"EC": _safe_float(ec, 0.0),
|
||||
}
|
||||
if site_parameters:
|
||||
resolved_site.update(site_parameters)
|
||||
|
||||
simulation_profile = _extract_plant_simulation_profile(plant)
|
||||
default_crop = (
|
||||
deepcopy(simulation_profile.get("crop_parameters"))
|
||||
if simulation_profile and isinstance(simulation_profile.get("crop_parameters"), dict)
|
||||
else _build_default_crop_parameters(plant, plant_name or getattr(plant, "name", "crop"))
|
||||
)
|
||||
resolved_crop = default_crop
|
||||
if crop_parameters:
|
||||
resolved_crop.update(crop_parameters)
|
||||
resolved_crop.setdefault("crop_name", plant_name or getattr(plant, "name", "crop"))
|
||||
resolved_crop.setdefault("farm_uuid", str(farm_uuid))
|
||||
resolved_crop.setdefault("soil_ph", _safe_float(soil_ph, 7.0))
|
||||
resolved_crop.setdefault("soil_nitrogen", _safe_float(nitrogen, DEFAULT_NAVAILI))
|
||||
resolved_crop.setdefault("soil_phosphorus", _safe_float(phosphorus, 0.0))
|
||||
resolved_crop.setdefault("soil_potassium", _safe_float(potassium, 0.0))
|
||||
|
||||
default_agromanagement = (
|
||||
deepcopy(simulation_profile.get("agromanagement"))
|
||||
if simulation_profile and simulation_profile.get("agromanagement")
|
||||
else _build_default_agromanagement(resolved_crop["crop_name"], resolved_weather)
|
||||
)
|
||||
resolved_agromanagement = agromanagement if agromanagement is not None else default_agromanagement
|
||||
|
||||
return {
|
||||
"farm": farm,
|
||||
"plant": plant,
|
||||
"weather": resolved_weather,
|
||||
"soil": resolved_soil,
|
||||
"site_parameters": resolved_site,
|
||||
"crop_parameters": resolved_crop,
|
||||
"agromanagement": resolved_agromanagement,
|
||||
}
|
||||
|
||||
|
||||
def _extract_total_n(agromanagement: list[dict[str, Any]]) -> float:
|
||||
total_n = 0.0
|
||||
for campaign in agromanagement:
|
||||
@@ -250,6 +513,73 @@ def _extract_total_n(agromanagement: list[dict[str, Any]]) -> float:
|
||||
return total_n
|
||||
|
||||
|
||||
def _estimate_pk_stress_factor(
|
||||
*,
|
||||
soil: dict[str, Any],
|
||||
site: dict[str, Any],
|
||||
crop: dict[str, Any],
|
||||
) -> dict[str, float]:
|
||||
phosphorus = _safe_float(
|
||||
_pick_first_not_none(site.get("P_STATUS"), soil.get("phosphorus"), crop.get("soil_phosphorus")),
|
||||
0.0,
|
||||
)
|
||||
potassium = _safe_float(
|
||||
_pick_first_not_none(site.get("K_STATUS"), soil.get("potassium"), crop.get("soil_potassium")),
|
||||
0.0,
|
||||
)
|
||||
soil_ph = _safe_float(
|
||||
_pick_first_not_none(site.get("SOIL_PH"), soil.get("soil_ph"), crop.get("soil_ph")),
|
||||
7.0,
|
||||
)
|
||||
ec = _safe_float(_pick_first_not_none(site.get("EC"), soil.get("electrical_conductivity")), 0.0)
|
||||
|
||||
phosphorus_target = _safe_float(crop.get("P_OPTIMAL"), 30.0)
|
||||
potassium_target = _safe_float(crop.get("K_OPTIMAL"), 45.0)
|
||||
p_factor = _clamp(phosphorus / max(phosphorus_target, 1.0), 0.45, 1.0)
|
||||
k_factor = _clamp(potassium / max(potassium_target, 1.0), 0.45, 1.0)
|
||||
|
||||
ph_penalty = 1.0
|
||||
if soil_ph < 5.8:
|
||||
ph_penalty = _clamp(1.0 - ((5.8 - soil_ph) * 0.08), 0.65, 1.0)
|
||||
elif soil_ph > 7.8:
|
||||
ph_penalty = _clamp(1.0 - ((soil_ph - 7.8) * 0.06), 0.7, 1.0)
|
||||
|
||||
ec_penalty = 1.0
|
||||
if ec > 2.5:
|
||||
ec_penalty = _clamp(1.0 - ((ec - 2.5) * 0.07), 0.72, 1.0)
|
||||
|
||||
combined_factor = round(_clamp(p_factor * k_factor * ph_penalty * ec_penalty, 0.35, 1.0), 4)
|
||||
return {
|
||||
"phosphorus_factor": round(p_factor, 4),
|
||||
"potassium_factor": round(k_factor, 4),
|
||||
"ph_penalty": round(ph_penalty, 4),
|
||||
"ec_penalty": round(ec_penalty, 4),
|
||||
"combined_factor": combined_factor,
|
||||
}
|
||||
|
||||
|
||||
def _apply_pk_adjustment(
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
soil: dict[str, Any],
|
||||
site: dict[str, Any],
|
||||
crop: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
adjustment = _estimate_pk_stress_factor(soil=soil, site=site, crop=crop)
|
||||
factor = adjustment["combined_factor"]
|
||||
if factor >= 0.995:
|
||||
result["nutrient_adjustment"] = adjustment
|
||||
return result
|
||||
|
||||
metrics = dict(result.get("metrics", {}))
|
||||
for key, scale in {"yield_estimate": factor, "biomass": factor, "max_lai": max(factor, 0.6)}.items():
|
||||
if metrics.get(key) is not None:
|
||||
metrics[key] = round(_safe_float(metrics[key]) * scale, 4)
|
||||
result["metrics"] = metrics
|
||||
result["nutrient_adjustment"] = adjustment
|
||||
return result
|
||||
|
||||
|
||||
def _load_pcse_bindings() -> dict[str, Any] | None:
|
||||
try:
|
||||
base_module = importlib.import_module("pcse.base")
|
||||
@@ -288,7 +618,7 @@ class PreparedSimulationInput:
|
||||
|
||||
|
||||
class PcseSimulationManager:
|
||||
def __init__(self, model_name: str = "Wofost72_WLP_CWB"):
|
||||
def __init__(self, model_name: str = DEFAULT_PCSE_MODEL_NAME):
|
||||
self.model_name = model_name
|
||||
|
||||
def run_simulation(
|
||||
@@ -304,7 +634,11 @@ class PcseSimulationManager:
|
||||
weather=_normalize_weather_records(weather),
|
||||
soil=soil or {},
|
||||
crop=crop_parameters or {},
|
||||
site=site_parameters or {},
|
||||
site=_normalize_site_parameters_for_model(
|
||||
self.model_name,
|
||||
site_parameters or {},
|
||||
soil_parameters=soil or {},
|
||||
),
|
||||
agromanagement=_normalize_agromanagement(agromanagement),
|
||||
)
|
||||
bindings = _load_pcse_bindings()
|
||||
@@ -312,7 +646,15 @@ class PcseSimulationManager:
|
||||
raise CropSimulationError(
|
||||
"PCSE is not installed or required PCSE classes could not be loaded."
|
||||
)
|
||||
return self._run_with_pcse(prepared, bindings)
|
||||
result = self._run_with_pcse(prepared, bindings)
|
||||
if self.model_name.startswith("Wofost81_NWLP"):
|
||||
result = _apply_pk_adjustment(
|
||||
result,
|
||||
soil=prepared.soil,
|
||||
site=prepared.site,
|
||||
crop=prepared.crop,
|
||||
)
|
||||
return result
|
||||
|
||||
def _run_with_pcse(
|
||||
self,
|
||||
@@ -407,20 +749,73 @@ class CropSimulationService:
|
||||
def __init__(self, manager: PcseSimulationManager | None = None):
|
||||
self.manager = manager or PcseSimulationManager()
|
||||
|
||||
def _resolve_common_inputs(
|
||||
self,
|
||||
*,
|
||||
farm_uuid: str | None = None,
|
||||
plant_name: str | None = None,
|
||||
weather: Any | None = None,
|
||||
soil: dict[str, Any] | None = None,
|
||||
crop_parameters: dict[str, Any] | None = None,
|
||||
agromanagement: Any | None = None,
|
||||
site_parameters: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not farm_uuid:
|
||||
return {
|
||||
"weather": weather,
|
||||
"soil": soil or {},
|
||||
"crop_parameters": crop_parameters or {},
|
||||
"agromanagement": agromanagement,
|
||||
"site_parameters": _normalize_site_parameters_for_model(
|
||||
self.manager.model_name,
|
||||
site_parameters or {},
|
||||
soil_parameters=soil or {},
|
||||
),
|
||||
"farm": None,
|
||||
"plant": None,
|
||||
}
|
||||
|
||||
base = build_simulation_payload_from_farm(
|
||||
farm_uuid=str(farm_uuid),
|
||||
plant_name=plant_name or (crop_parameters or {}).get("crop_name"),
|
||||
weather=weather,
|
||||
soil=soil,
|
||||
crop_parameters=crop_parameters,
|
||||
agromanagement=agromanagement,
|
||||
site_parameters=site_parameters,
|
||||
)
|
||||
base["site_parameters"] = _normalize_site_parameters_for_model(
|
||||
self.manager.model_name,
|
||||
base.get("site_parameters"),
|
||||
soil_parameters=base.get("soil"),
|
||||
)
|
||||
return base
|
||||
|
||||
def run_single_simulation(
|
||||
self,
|
||||
*,
|
||||
weather: Any,
|
||||
soil: dict[str, Any],
|
||||
crop_parameters: dict[str, Any],
|
||||
agromanagement: Any,
|
||||
weather: Any | None = None,
|
||||
soil: dict[str, Any] | None = None,
|
||||
crop_parameters: dict[str, Any] | None = None,
|
||||
agromanagement: Any | None = None,
|
||||
site_parameters: dict[str, Any] | None = None,
|
||||
irrigation_recommendation: dict[str, Any] | None = None,
|
||||
fertilization_recommendation: dict[str, Any] | None = None,
|
||||
name: str = "",
|
||||
farm_uuid: str | None = None,
|
||||
plant_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
resolved = self._resolve_common_inputs(
|
||||
farm_uuid=farm_uuid,
|
||||
plant_name=plant_name,
|
||||
weather=weather,
|
||||
soil=soil,
|
||||
crop_parameters=crop_parameters,
|
||||
agromanagement=agromanagement,
|
||||
site_parameters=site_parameters,
|
||||
)
|
||||
merged_agromanagement = _merge_management_recommendations(
|
||||
agromanagement,
|
||||
resolved["agromanagement"],
|
||||
irrigation_recommendation=irrigation_recommendation,
|
||||
fertilization_recommendation=fertilization_recommendation,
|
||||
)
|
||||
@@ -430,13 +825,15 @@ class CropSimulationService:
|
||||
model_name=self.manager.model_name,
|
||||
input_payload=_json_ready(
|
||||
{
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"crop_parameters": crop_parameters,
|
||||
"site_parameters": site_parameters or {},
|
||||
"weather": resolved["weather"],
|
||||
"soil": resolved["soil"],
|
||||
"crop_parameters": resolved["crop_parameters"],
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"agromanagement": merged_agromanagement,
|
||||
"irrigation_recommendation": irrigation_recommendation or {},
|
||||
"fertilization_recommendation": fertilization_recommendation or {},
|
||||
"farm_uuid": farm_uuid,
|
||||
"plant_name": plant_name,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -444,10 +841,10 @@ class CropSimulationService:
|
||||
scenario=scenario,
|
||||
run_key="single",
|
||||
label=name or "single",
|
||||
weather_payload=_json_ready(weather),
|
||||
soil_payload=_json_ready(soil),
|
||||
crop_payload=_json_ready(crop_parameters),
|
||||
site_payload=_json_ready(site_parameters or {}),
|
||||
weather_payload=_json_ready(resolved["weather"]),
|
||||
soil_payload=_json_ready(resolved["soil"]),
|
||||
crop_payload=_json_ready(resolved["crop_parameters"]),
|
||||
site_payload=_json_ready(resolved["site_parameters"]),
|
||||
agromanagement_payload=_json_ready(merged_agromanagement),
|
||||
)
|
||||
return self._execute_scenario(
|
||||
@@ -455,10 +852,10 @@ class CropSimulationService:
|
||||
run_specs=[
|
||||
{
|
||||
"instance": run,
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"crop_parameters": crop_parameters,
|
||||
"site_parameters": site_parameters or {},
|
||||
"weather": resolved["weather"],
|
||||
"soil": resolved["soil"],
|
||||
"crop_parameters": resolved["crop_parameters"],
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"agromanagement": merged_agromanagement,
|
||||
}
|
||||
],
|
||||
@@ -467,18 +864,27 @@ class CropSimulationService:
|
||||
def compare_crops(
|
||||
self,
|
||||
*,
|
||||
weather: Any,
|
||||
soil: dict[str, Any],
|
||||
weather: Any | None = None,
|
||||
soil: dict[str, Any] | None = None,
|
||||
crop_a: dict[str, Any],
|
||||
crop_b: dict[str, Any],
|
||||
agromanagement: Any,
|
||||
agromanagement: Any | None = None,
|
||||
site_parameters: dict[str, Any] | None = None,
|
||||
irrigation_recommendation: dict[str, Any] | None = None,
|
||||
fertilization_recommendation: dict[str, Any] | None = None,
|
||||
name: str = "",
|
||||
farm_uuid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
resolved = self._resolve_common_inputs(
|
||||
farm_uuid=farm_uuid,
|
||||
weather=weather,
|
||||
soil=soil,
|
||||
crop_parameters=None,
|
||||
agromanagement=agromanagement,
|
||||
site_parameters=site_parameters,
|
||||
)
|
||||
merged_agromanagement = _merge_management_recommendations(
|
||||
agromanagement,
|
||||
resolved["agromanagement"],
|
||||
irrigation_recommendation=irrigation_recommendation,
|
||||
fertilization_recommendation=fertilization_recommendation,
|
||||
)
|
||||
@@ -488,14 +894,15 @@ class CropSimulationService:
|
||||
model_name=self.manager.model_name,
|
||||
input_payload=_json_ready(
|
||||
{
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"weather": resolved["weather"],
|
||||
"soil": resolved["soil"],
|
||||
"crop_a": crop_a,
|
||||
"crop_b": crop_b,
|
||||
"site_parameters": site_parameters or {},
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"agromanagement": merged_agromanagement,
|
||||
"irrigation_recommendation": irrigation_recommendation or {},
|
||||
"fertilization_recommendation": fertilization_recommendation or {},
|
||||
"farm_uuid": farm_uuid,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -504,20 +911,20 @@ class CropSimulationService:
|
||||
scenario=scenario,
|
||||
run_key="crop_a",
|
||||
label=crop_a.get("crop_name", "crop_a"),
|
||||
weather_payload=_json_ready(weather),
|
||||
soil_payload=_json_ready(soil),
|
||||
weather_payload=_json_ready(resolved["weather"]),
|
||||
soil_payload=_json_ready(resolved["soil"]),
|
||||
crop_payload=_json_ready(crop_a),
|
||||
site_payload=_json_ready(site_parameters or {}),
|
||||
site_payload=_json_ready(resolved["site_parameters"]),
|
||||
agromanagement_payload=_json_ready(merged_agromanagement),
|
||||
),
|
||||
SimulationRun.objects.create(
|
||||
scenario=scenario,
|
||||
run_key="crop_b",
|
||||
label=crop_b.get("crop_name", "crop_b"),
|
||||
weather_payload=_json_ready(weather),
|
||||
soil_payload=_json_ready(soil),
|
||||
weather_payload=_json_ready(resolved["weather"]),
|
||||
soil_payload=_json_ready(resolved["soil"]),
|
||||
crop_payload=_json_ready(crop_b),
|
||||
site_payload=_json_ready(site_parameters or {}),
|
||||
site_payload=_json_ready(resolved["site_parameters"]),
|
||||
agromanagement_payload=_json_ready(merged_agromanagement),
|
||||
),
|
||||
]
|
||||
@@ -526,18 +933,18 @@ class CropSimulationService:
|
||||
run_specs=[
|
||||
{
|
||||
"instance": runs[0],
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"weather": resolved["weather"],
|
||||
"soil": resolved["soil"],
|
||||
"crop_parameters": crop_a,
|
||||
"site_parameters": site_parameters or {},
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"agromanagement": merged_agromanagement,
|
||||
},
|
||||
{
|
||||
"instance": runs[1],
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"weather": resolved["weather"],
|
||||
"soil": resolved["soil"],
|
||||
"crop_parameters": crop_b,
|
||||
"site_parameters": site_parameters or {},
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"agromanagement": merged_agromanagement,
|
||||
},
|
||||
],
|
||||
@@ -546,20 +953,44 @@ class CropSimulationService:
|
||||
def recommend_best_crop(
|
||||
self,
|
||||
*,
|
||||
weather: Any,
|
||||
soil: dict[str, Any],
|
||||
crops: list[dict[str, Any]],
|
||||
agromanagement: Any,
|
||||
weather: Any | None = None,
|
||||
soil: dict[str, Any] | None = None,
|
||||
crops: list[dict[str, Any]] | None = None,
|
||||
agromanagement: Any | None = None,
|
||||
site_parameters: dict[str, Any] | None = None,
|
||||
irrigation_recommendation: dict[str, Any] | None = None,
|
||||
fertilization_recommendation: dict[str, Any] | None = None,
|
||||
name: str = "",
|
||||
farm_uuid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not crops and farm_uuid:
|
||||
base = build_simulation_payload_from_farm(farm_uuid=str(farm_uuid))
|
||||
crops = []
|
||||
for plant in base["farm"].plants.all():
|
||||
simulation_profile = _extract_plant_simulation_profile(plant)
|
||||
crop_payload = (
|
||||
deepcopy(simulation_profile.get("crop_parameters"))
|
||||
if simulation_profile and isinstance(simulation_profile.get("crop_parameters"), dict)
|
||||
else _build_default_crop_parameters(plant, plant.name)
|
||||
)
|
||||
crop_payload.setdefault("crop_name", plant.name)
|
||||
crop_payload.setdefault("label", plant.name)
|
||||
crops.append(crop_payload)
|
||||
|
||||
crops = crops or []
|
||||
if len(crops) < 2:
|
||||
raise CropSimulationError("At least two crop options are required.")
|
||||
|
||||
resolved = self._resolve_common_inputs(
|
||||
farm_uuid=farm_uuid,
|
||||
weather=weather,
|
||||
soil=soil,
|
||||
crop_parameters=None,
|
||||
agromanagement=agromanagement,
|
||||
site_parameters=site_parameters,
|
||||
)
|
||||
merged_agromanagement = _merge_management_recommendations(
|
||||
agromanagement,
|
||||
resolved["agromanagement"],
|
||||
irrigation_recommendation=irrigation_recommendation,
|
||||
fertilization_recommendation=fertilization_recommendation,
|
||||
)
|
||||
@@ -570,13 +1001,14 @@ class CropSimulationService:
|
||||
model_name=self.manager.model_name,
|
||||
input_payload=_json_ready(
|
||||
{
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"weather": resolved["weather"],
|
||||
"soil": resolved["soil"],
|
||||
"crops": crops,
|
||||
"site_parameters": site_parameters or {},
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"agromanagement": merged_agromanagement,
|
||||
"irrigation_recommendation": irrigation_recommendation or {},
|
||||
"fertilization_recommendation": fertilization_recommendation or {},
|
||||
"farm_uuid": farm_uuid,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -593,19 +1025,19 @@ class CropSimulationService:
|
||||
scenario=scenario,
|
||||
run_key=f"crop_{index}",
|
||||
label=label,
|
||||
weather_payload=_json_ready(weather),
|
||||
soil_payload=_json_ready(soil),
|
||||
weather_payload=_json_ready(resolved["weather"]),
|
||||
soil_payload=_json_ready(resolved["soil"]),
|
||||
crop_payload=_json_ready(crop),
|
||||
site_payload=_json_ready(site_parameters or {}),
|
||||
site_payload=_json_ready(resolved["site_parameters"]),
|
||||
agromanagement_payload=_json_ready(merged_agromanagement),
|
||||
)
|
||||
run_specs.append(
|
||||
{
|
||||
"instance": run,
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"weather": resolved["weather"],
|
||||
"soil": resolved["soil"],
|
||||
"crop_parameters": crop,
|
||||
"site_parameters": site_parameters or {},
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"agromanagement": merged_agromanagement,
|
||||
}
|
||||
)
|
||||
@@ -627,31 +1059,44 @@ class CropSimulationService:
|
||||
def compare_fertilization_strategies(
|
||||
self,
|
||||
*,
|
||||
weather: Any,
|
||||
soil: dict[str, Any],
|
||||
crop_parameters: dict[str, Any],
|
||||
weather: Any | None = None,
|
||||
soil: dict[str, Any] | None = None,
|
||||
crop_parameters: dict[str, Any] | None = None,
|
||||
strategies: list[dict[str, Any]],
|
||||
site_parameters: dict[str, Any] | None = None,
|
||||
irrigation_recommendation: dict[str, Any] | None = None,
|
||||
fertilization_recommendation: dict[str, Any] | None = None,
|
||||
name: str = "",
|
||||
farm_uuid: str | None = None,
|
||||
plant_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if len(strategies) < 2:
|
||||
raise CropSimulationError("At least two fertilization strategies are required.")
|
||||
|
||||
resolved = self._resolve_common_inputs(
|
||||
farm_uuid=farm_uuid,
|
||||
plant_name=plant_name,
|
||||
weather=weather,
|
||||
soil=soil,
|
||||
crop_parameters=crop_parameters,
|
||||
agromanagement=None,
|
||||
site_parameters=site_parameters,
|
||||
)
|
||||
scenario = SimulationScenario.objects.create(
|
||||
name=name,
|
||||
scenario_type=SimulationScenario.ScenarioType.FERTILIZATION_COMPARISON,
|
||||
model_name=self.manager.model_name,
|
||||
input_payload=_json_ready(
|
||||
{
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"crop_parameters": crop_parameters,
|
||||
"site_parameters": site_parameters or {},
|
||||
"weather": resolved["weather"],
|
||||
"soil": resolved["soil"],
|
||||
"crop_parameters": resolved["crop_parameters"],
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"strategies": strategies,
|
||||
"irrigation_recommendation": irrigation_recommendation or {},
|
||||
"fertilization_recommendation": fertilization_recommendation or {},
|
||||
"farm_uuid": farm_uuid,
|
||||
"plant_name": plant_name,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -666,19 +1111,19 @@ class CropSimulationService:
|
||||
scenario=scenario,
|
||||
run_key=f"strategy_{index}",
|
||||
label=strategy.get("label", f"strategy_{index}"),
|
||||
weather_payload=_json_ready(weather),
|
||||
soil_payload=_json_ready(soil),
|
||||
crop_payload=_json_ready(crop_parameters),
|
||||
site_payload=_json_ready(site_parameters or {}),
|
||||
weather_payload=_json_ready(resolved["weather"]),
|
||||
soil_payload=_json_ready(resolved["soil"]),
|
||||
crop_payload=_json_ready(resolved["crop_parameters"]),
|
||||
site_payload=_json_ready(resolved["site_parameters"]),
|
||||
agromanagement_payload=_json_ready(merged_agromanagement),
|
||||
)
|
||||
run_specs.append(
|
||||
{
|
||||
"instance": run,
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"crop_parameters": crop_parameters,
|
||||
"site_parameters": site_parameters or {},
|
||||
"weather": resolved["weather"],
|
||||
"soil": resolved["soil"],
|
||||
"crop_parameters": resolved["crop_parameters"],
|
||||
"site_parameters": resolved["site_parameters"],
|
||||
"agromanagement": merged_agromanagement,
|
||||
}
|
||||
)
|
||||
@@ -797,10 +1242,11 @@ class CropSimulationService:
|
||||
"runs": run_metrics,
|
||||
}
|
||||
if scenario.scenario_type == SimulationScenario.ScenarioType.CROP_COMPARISON:
|
||||
payload["comparison"]["yield_gap"] = round(
|
||||
abs(run_metrics[0]["yield_estimate"] - run_metrics[1]["yield_estimate"]),
|
||||
3,
|
||||
)
|
||||
if len(run_metrics) >= 2:
|
||||
payload["comparison"]["yield_gap"] = round(
|
||||
abs(run_metrics[0]["yield_estimate"] - run_metrics[1]["yield_estimate"]),
|
||||
3,
|
||||
)
|
||||
if (
|
||||
scenario.scenario_type
|
||||
== SimulationScenario.ScenarioType.FERTILIZATION_COMPARISON
|
||||
|
||||
Reference in New Issue
Block a user