This commit is contained in:
2026-04-30 00:53:47 +03:30
parent 88f56da582
commit 46ba01e4cc
13 changed files with 2925 additions and 20 deletions
+95 -9
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import importlib
from copy import deepcopy
from dataclasses import dataclass
from datetime import date, datetime
from datetime import date, datetime, timedelta
from typing import Any
from django.db import transaction
@@ -82,6 +82,11 @@ def _normalize_weather_records(weather: Any) -> list[dict[str, Any]]:
return normalized
def _mm_to_cm_day(value: Any, default: float) -> float:
scaled = _safe_float(value, default * 10.0) / 10.0
return round(max(scaled, 0.0), 4)
def _normalize_agromanagement(agromanagement: Any) -> list[dict[str, Any]]:
if isinstance(agromanagement, dict) and "AgroManagement" in agromanagement:
campaigns = agromanagement["AgroManagement"]
@@ -94,7 +99,58 @@ def _normalize_agromanagement(agromanagement: Any) -> list[dict[str, Any]]:
if not campaigns:
raise CropSimulationError("Agromanagement input cannot be empty.")
return campaigns
return _ensure_trailing_empty_campaign(campaigns)
def _ensure_trailing_empty_campaign(campaigns: list[dict[str, Any]]) -> list[dict[str, Any]]:
normalized = list(campaigns)
if not normalized:
return normalized
last_campaign = normalized[-1]
if _is_explicit_empty_campaign(last_campaign):
return normalized
trailing = _build_trailing_empty_campaign(normalized)
if last_campaign == {}:
normalized[-1] = trailing
else:
normalized.append(trailing)
return normalized
def _is_explicit_empty_campaign(campaign: dict[str, Any]) -> bool:
if not isinstance(campaign, dict) or len(campaign) != 1:
return False
start_date, payload = next(iter(campaign.items()))
return isinstance(start_date, date) and payload is None
def _build_trailing_empty_campaign(campaigns: list[dict[str, Any]]) -> dict[date, None]:
last_campaign = next((item for item in reversed(campaigns) if isinstance(item, dict) and item), None)
if not last_campaign:
return {date.today(): None}
campaign_start, campaign_payload = next(iter(last_campaign.items()))
candidate_dates = [_coerce_date(campaign_start)]
if isinstance(campaign_payload, dict):
crop_calendar = campaign_payload.get("CropCalendar") or {}
for field_name in ("crop_end_date", "crop_start_date"):
value = crop_calendar.get(field_name)
if value:
candidate_dates.append(_coerce_date(value))
for bucket_name in ("TimedEvents",):
for event_group in campaign_payload.get(bucket_name, []) or []:
if not isinstance(event_group, dict):
continue
for event in event_group.get("events_table", []) or []:
if not isinstance(event, dict) or not event:
continue
event_date = next(iter(event.keys()))
candidate_dates.append(_coerce_date(event_date))
return {max(candidate_dates) + timedelta(days=1): None}
def _deep_copy_json_like(value: Any) -> Any:
@@ -296,7 +352,7 @@ def _build_default_agromanagement(crop_name: str, weather: 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 [
return _ensure_trailing_empty_campaign([
{
first_day: {
"CropCalendar": {
@@ -312,7 +368,7 @@ def _build_default_agromanagement(crop_name: str, weather: list[dict[str, Any]])
"StateEvents": [],
}
}
]
])
def _build_weather_from_forecasts(forecasts: list[Any], *, latitude: float, longitude: float) -> list[dict[str, Any]]:
@@ -333,10 +389,11 @@ def _build_weather_from_forecasts(forecasts: list[Any], *, latitude: float, long
),
"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),
# WeatherForecast stores precipitation/ET0 in mm/day, while PCSE expects cm/day.
"RAIN": _mm_to_cm_day(forecast.precipitation, 0.0),
"E0": _mm_to_cm_day(forecast.et0, 0.35),
"ES0": max(round(_mm_to_cm_day(forecast.et0, 0.35) * 0.9, 4), 0.1),
"ET0": _mm_to_cm_day(forecast.et0, 0.35),
}
for forecast in forecasts
]
@@ -352,6 +409,17 @@ def _normalize_site_parameters_for_model(
soil = soil_parameters or {}
site.setdefault("WAV", _safe_float(site.get("WAV"), DEFAULT_WAV))
smw = _safe_float(soil.get("SMW"), 0.14)
smfcf = _safe_float(soil.get("SMFCF"), 0.34)
sm0 = _safe_float(
_pick_first_not_none(soil.get("SM0"), soil.get("SMMAX")),
min(max(smfcf + 0.08, smw + 0.12), 0.6),
)
site.setdefault("IFUNRN", 0)
site.setdefault("NOTINF", 0.0)
site.setdefault("SSI", 0.0)
site.setdefault("SSMAX", 0.0)
site.setdefault("SMLIM", round(_clamp(_safe_float(site.get("SMLIM"), smfcf), smw, sm0), 3))
if model_name.startswith("Wofost81_NWLP"):
navaili = _pick_first_not_none(
site.get("NAVAILI"),
@@ -416,6 +484,14 @@ def build_simulation_payload_from_farm(
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))
sm0 = _clamp(
_safe_float(
_pick_first_not_none(getattr(top_depth, "porosity", None), getattr(top_depth, "wv0000", None)),
min(max(smfcf + 0.08, smw + 0.12), 0.6),
),
max(smfcf + 0.02, smw + 0.05),
0.8,
)
soil_moisture = _sensor_metric(farm, "soil_moisture")
wav = (
round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3)
@@ -431,7 +507,11 @@ def build_simulation_payload_from_farm(
resolved_soil = {
"SMFCF": round(smfcf, 3),
"SMW": round(smw, 3),
"SM0": round(sm0, 3),
"RDMSOL": 120.0,
"CRAIRC": 0.06,
"SOPE": 10.0,
"KSUB": 10.0,
"soil_moisture": soil_moisture,
"nitrogen": _safe_float(nitrogen, DEFAULT_NAVAILI),
"phosphorus": _safe_float(phosphorus, 0.0),
@@ -454,6 +534,11 @@ def build_simulation_payload_from_farm(
"K_STATUS": _safe_float(potassium, 0.0),
"SOIL_PH": _safe_float(soil_ph, 7.0),
"EC": _safe_float(ec, 0.0),
"IFUNRN": 0,
"NOTINF": 0.0,
"SSI": 0.0,
"SSMAX": 0.0,
"SMLIM": round(_clamp(_safe_float(_pick_first_not_none(site_parameters and site_parameters.get("SMLIM"), smfcf), smfcf), smw, sm0), 3),
}
if site_parameters:
resolved_site.update(site_parameters)
@@ -469,10 +554,11 @@ def build_simulation_payload_from_farm(
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))
# Keep pH in soil/site payloads only; duplicating it in cropdata breaks some PCSE parameter providers.
resolved_crop.pop("soil_ph", None)
default_agromanagement = (
deepcopy(simulation_profile.get("agromanagement"))