This commit is contained in:
2026-04-24 18:34:17 +03:30
parent 24ed5776bc
commit f7dc05dc9e
22 changed files with 3730 additions and 139 deletions
+240 -10
View File
@@ -93,6 +93,123 @@ def _normalize_agromanagement(agromanagement: Any) -> list[dict[str, Any]]:
return campaigns
def _deep_copy_json_like(value: Any) -> Any:
if isinstance(value, dict):
return {key: _deep_copy_json_like(item) for key, item in value.items()}
if isinstance(value, list):
return [_deep_copy_json_like(item) for item in value]
return value
def _parse_recommendation_events(
recommendation: dict[str, Any] | None,
*,
event_signal: str,
amount_keys: tuple[str, ...],
extra_keys: tuple[str, ...],
) -> list[dict[str, Any]]:
if not recommendation:
return []
raw_events = recommendation.get("events")
if raw_events is None:
raw_events = recommendation.get("schedule")
if raw_events is None:
raw_events = recommendation.get("applications")
if raw_events is None:
raw_events = recommendation.get("plan")
if not isinstance(raw_events, list):
return []
events_table = []
for item in raw_events:
if not isinstance(item, dict):
continue
raw_date = item.get("date") or item.get("day")
if raw_date is None:
continue
payload = {}
amount_value = None
amount_key = None
for candidate in amount_keys:
if item.get(candidate) is not None:
amount_value = item.get(candidate)
amount_key = candidate
break
if amount_key is not None:
payload[amount_key] = float(amount_value)
for extra_key in extra_keys:
if item.get(extra_key) is not None:
payload[extra_key] = float(item[extra_key])
if payload:
events_table.append({_coerce_date(raw_date): payload})
if not events_table:
return []
return [
{
"event_signal": event_signal,
"name": recommendation.get("name", f"{event_signal} recommendation"),
"comment": recommendation.get("comment", ""),
"events_table": events_table,
}
]
def _merge_management_recommendations(
agromanagement: Any,
*,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
campaigns = _deep_copy_json_like(_normalize_agromanagement(agromanagement))
irrigation_events = _parse_recommendation_events(
irrigation_recommendation,
event_signal="irrigate",
amount_keys=("amount", "irrigation_amount"),
extra_keys=("efficiency",),
)
fertilization_events = _parse_recommendation_events(
fertilization_recommendation,
event_signal="apply_n",
amount_keys=("N_amount", "amount"),
extra_keys=("N_recovery",),
)
if not irrigation_events and not fertilization_events:
return campaigns
target_campaign = None
for campaign in campaigns:
if isinstance(campaign, dict) and campaign:
target_campaign = campaign
break
if target_campaign is None:
raise CropSimulationError(
"Agromanagement must contain at least one non-empty campaign."
)
campaign_start = next(iter(target_campaign.keys()))
campaign_payload = target_campaign[campaign_start]
if not isinstance(campaign_payload, dict):
raise CropSimulationError("Agromanagement campaign payload must be a dictionary.")
timed_events = campaign_payload.get("TimedEvents")
if timed_events in (None, ""):
timed_events = []
if not isinstance(timed_events, list):
raise CropSimulationError("TimedEvents must be a list when recommendations are merged.")
timed_events.extend(irrigation_events)
timed_events.extend(fertilization_events)
campaign_payload["TimedEvents"] = timed_events
return campaigns
def _normalize_pcse_output_records(records: Any) -> list[dict[str, Any]]:
if records is None:
return []
@@ -298,8 +415,15 @@ class CropSimulationService:
crop_parameters: dict[str, Any],
agromanagement: Any,
site_parameters: dict[str, Any] | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
name: str = "",
) -> dict[str, Any]:
merged_agromanagement = _merge_management_recommendations(
agromanagement,
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
scenario = SimulationScenario.objects.create(
name=name,
scenario_type=SimulationScenario.ScenarioType.SINGLE,
@@ -310,7 +434,9 @@ class CropSimulationService:
"soil": soil,
"crop_parameters": crop_parameters,
"site_parameters": site_parameters or {},
"agromanagement": agromanagement,
"agromanagement": merged_agromanagement,
"irrigation_recommendation": irrigation_recommendation or {},
"fertilization_recommendation": fertilization_recommendation or {},
}
),
)
@@ -322,7 +448,7 @@ class CropSimulationService:
soil_payload=_json_ready(soil),
crop_payload=_json_ready(crop_parameters),
site_payload=_json_ready(site_parameters or {}),
agromanagement_payload=_json_ready(agromanagement),
agromanagement_payload=_json_ready(merged_agromanagement),
)
return self._execute_scenario(
scenario=scenario,
@@ -333,7 +459,7 @@ class CropSimulationService:
"soil": soil,
"crop_parameters": crop_parameters,
"site_parameters": site_parameters or {},
"agromanagement": agromanagement,
"agromanagement": merged_agromanagement,
}
],
)
@@ -347,8 +473,15 @@ class CropSimulationService:
crop_b: dict[str, Any],
agromanagement: Any,
site_parameters: dict[str, Any] | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
name: str = "",
) -> dict[str, Any]:
merged_agromanagement = _merge_management_recommendations(
agromanagement,
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
scenario = SimulationScenario.objects.create(
name=name,
scenario_type=SimulationScenario.ScenarioType.CROP_COMPARISON,
@@ -360,7 +493,9 @@ class CropSimulationService:
"crop_a": crop_a,
"crop_b": crop_b,
"site_parameters": site_parameters or {},
"agromanagement": agromanagement,
"agromanagement": merged_agromanagement,
"irrigation_recommendation": irrigation_recommendation or {},
"fertilization_recommendation": fertilization_recommendation or {},
}
),
)
@@ -373,7 +508,7 @@ class CropSimulationService:
soil_payload=_json_ready(soil),
crop_payload=_json_ready(crop_a),
site_payload=_json_ready(site_parameters or {}),
agromanagement_payload=_json_ready(agromanagement),
agromanagement_payload=_json_ready(merged_agromanagement),
),
SimulationRun.objects.create(
scenario=scenario,
@@ -383,7 +518,7 @@ class CropSimulationService:
soil_payload=_json_ready(soil),
crop_payload=_json_ready(crop_b),
site_payload=_json_ready(site_parameters or {}),
agromanagement_payload=_json_ready(agromanagement),
agromanagement_payload=_json_ready(merged_agromanagement),
),
]
return self._execute_scenario(
@@ -395,7 +530,7 @@ class CropSimulationService:
"soil": soil,
"crop_parameters": crop_a,
"site_parameters": site_parameters or {},
"agromanagement": agromanagement,
"agromanagement": merged_agromanagement,
},
{
"instance": runs[1],
@@ -403,11 +538,92 @@ class CropSimulationService:
"soil": soil,
"crop_parameters": crop_b,
"site_parameters": site_parameters or {},
"agromanagement": agromanagement,
"agromanagement": merged_agromanagement,
},
],
)
def recommend_best_crop(
self,
*,
weather: Any,
soil: dict[str, Any],
crops: list[dict[str, Any]],
agromanagement: Any,
site_parameters: dict[str, Any] | None = None,
irrigation_recommendation: dict[str, Any] | None = None,
fertilization_recommendation: dict[str, Any] | None = None,
name: str = "",
) -> dict[str, Any]:
if len(crops) < 2:
raise CropSimulationError("At least two crop options are required.")
merged_agromanagement = _merge_management_recommendations(
agromanagement,
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
scenario = SimulationScenario.objects.create(
name=name,
scenario_type=SimulationScenario.ScenarioType.CROP_COMPARISON,
model_name=self.manager.model_name,
input_payload=_json_ready(
{
"weather": weather,
"soil": soil,
"crops": crops,
"site_parameters": site_parameters or {},
"agromanagement": merged_agromanagement,
"irrigation_recommendation": irrigation_recommendation or {},
"fertilization_recommendation": fertilization_recommendation or {},
}
),
)
run_specs = []
for index, crop in enumerate(crops, start=1):
label = (
crop.get("label")
or crop.get("crop_name")
or crop.get("name")
or f"crop_{index}"
)
run = SimulationRun.objects.create(
scenario=scenario,
run_key=f"crop_{index}",
label=label,
weather_payload=_json_ready(weather),
soil_payload=_json_ready(soil),
crop_payload=_json_ready(crop),
site_payload=_json_ready(site_parameters or {}),
agromanagement_payload=_json_ready(merged_agromanagement),
)
run_specs.append(
{
"instance": run,
"weather": weather,
"soil": soil,
"crop_parameters": crop,
"site_parameters": site_parameters or {},
"agromanagement": merged_agromanagement,
}
)
result = self._execute_scenario(scenario=scenario, run_specs=run_specs)
comparison = result.get("comparison", {})
return {
"scenario_id": result["scenario_id"],
"scenario_type": result["scenario_type"],
"recommended_crop": {
"run_key": comparison.get("best_run_key"),
"label": comparison.get("best_label"),
"expected_yield_estimate": comparison.get("best_yield_estimate"),
},
"candidates": comparison.get("runs", []),
"raw_result": result,
}
def compare_fertilization_strategies(
self,
*,
@@ -416,6 +632,8 @@ class CropSimulationService:
crop_parameters: dict[str, Any],
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 = "",
) -> dict[str, Any]:
if len(strategies) < 2:
@@ -432,11 +650,18 @@ class CropSimulationService:
"crop_parameters": crop_parameters,
"site_parameters": site_parameters or {},
"strategies": strategies,
"irrigation_recommendation": irrigation_recommendation or {},
"fertilization_recommendation": fertilization_recommendation or {},
}
),
)
run_specs = []
for index, strategy in enumerate(strategies, start=1):
merged_agromanagement = _merge_management_recommendations(
strategy["agromanagement"],
irrigation_recommendation=irrigation_recommendation,
fertilization_recommendation=fertilization_recommendation,
)
run = SimulationRun.objects.create(
scenario=scenario,
run_key=f"strategy_{index}",
@@ -445,7 +670,7 @@ class CropSimulationService:
soil_payload=_json_ready(soil),
crop_payload=_json_ready(crop_parameters),
site_payload=_json_ready(site_parameters or {}),
agromanagement_payload=_json_ready(strategy["agromanagement"]),
agromanagement_payload=_json_ready(merged_agromanagement),
)
run_specs.append(
{
@@ -454,7 +679,7 @@ class CropSimulationService:
"soil": soil,
"crop_parameters": crop_parameters,
"site_parameters": site_parameters or {},
"agromanagement": strategy["agromanagement"],
"agromanagement": merged_agromanagement,
}
)
return self._execute_scenario(scenario=scenario, run_specs=run_specs)
@@ -598,6 +823,11 @@ def compare_crops(**kwargs) -> dict[str, Any]:
return CropSimulationService().compare_crops(**kwargs)
@transaction.atomic
def recommend_best_crop(**kwargs) -> dict[str, Any]:
return CropSimulationService().recommend_best_crop(**kwargs)
@transaction.atomic
def compare_fertilization_strategies(**kwargs) -> dict[str, Any]:
return CropSimulationService().compare_fertilization_strategies(**kwargs)