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