UPDATE
This commit is contained in:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user