UPDATE
This commit is contained in:
@@ -60,3 +60,5 @@ htmlcov/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
logs/*
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
try:
|
||||||
from .celery import app as celery_app
|
from .celery import app as celery_app
|
||||||
|
except ImportError: # pragma: no cover - fallback for test environments
|
||||||
|
celery_app = None
|
||||||
|
|
||||||
__all__ = ("celery_app",)
|
__all__ = ("celery_app",)
|
||||||
|
|||||||
+21
-8
@@ -1,7 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
|
import importlib.util
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
except ImportError: # pragma: no cover - optional in stripped test envs
|
||||||
|
def load_dotenv():
|
||||||
|
return False
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -20,10 +25,6 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"rest_framework",
|
|
||||||
"corsheaders",
|
|
||||||
"drf_spectacular",
|
|
||||||
"drf_spectacular_sidecar",
|
|
||||||
"dashboard_data",
|
"dashboard_data",
|
||||||
"rag",
|
"rag",
|
||||||
"location_data",
|
"location_data",
|
||||||
@@ -32,11 +33,20 @@ INSTALLED_APPS = [
|
|||||||
"plant",
|
"plant",
|
||||||
"irrigation",
|
"irrigation",
|
||||||
"fertilization",
|
"fertilization",
|
||||||
|
"crop_simulation.apps.CropSimulationConfig",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
for optional_app in [
|
||||||
|
"rest_framework",
|
||||||
|
"corsheaders",
|
||||||
|
"drf_spectacular",
|
||||||
|
"drf_spectacular_sidecar",
|
||||||
|
]:
|
||||||
|
if importlib.util.find_spec(optional_app):
|
||||||
|
INSTALLED_APPS.insert(6, optional_app)
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
@@ -45,6 +55,9 @@ MIDDLEWARE = [
|
|||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if importlib.util.find_spec("corsheaders"):
|
||||||
|
MIDDLEWARE.insert(1, "corsheaders.middleware.CorsMiddleware")
|
||||||
|
|
||||||
ROOT_URLCONF = "config.urls"
|
ROOT_URLCONF = "config.urls"
|
||||||
WSGI_APPLICATION = "config.wsgi.application"
|
WSGI_APPLICATION = "config.wsgi.application"
|
||||||
|
|
||||||
@@ -72,12 +85,12 @@ DATABASES = {
|
|||||||
"PASSWORD": os.environ.get("DB_PASSWORD", ""),
|
"PASSWORD": os.environ.get("DB_PASSWORD", ""),
|
||||||
"HOST": os.environ.get("DB_HOST", "127.0.0.1"),
|
"HOST": os.environ.get("DB_HOST", "127.0.0.1"),
|
||||||
"PORT": os.environ.get("DB_PORT", "3306"),
|
"PORT": os.environ.get("DB_PORT", "3306"),
|
||||||
"OPTIONS": {
|
|
||||||
"charset": "utf8mb4",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if DATABASES["default"]["ENGINE"].endswith("mysql"):
|
||||||
|
DATABASES["default"]["OPTIONS"] = {"charset": "utf8mb4"}
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from .settings import * # noqa: F403,F401
|
||||||
|
|
||||||
|
ROOT_URLCONF = "config.test_urls"
|
||||||
|
|
||||||
|
LOGGING = { # noqa: F405
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"handlers": {"console": {"class": "logging.StreamHandler"}},
|
||||||
|
"root": {"handlers": ["console"], "level": "WARNING"},
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.http import HttpResponse
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
|
||||||
|
def test_view(_request):
|
||||||
|
return HttpResponse("ok")
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("__test__/", test_view),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CropSimulationConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "crop_simulation"
|
||||||
|
verbose_name = "Crop Simulation"
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SimulationScenario",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
(
|
||||||
|
"scenario_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("single", "Single Simulation"),
|
||||||
|
("crop_comparison", "Crop Comparison"),
|
||||||
|
("fertilization_comparison", "Fertilization Comparison"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="single",
|
||||||
|
max_length=64,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"model_name",
|
||||||
|
models.CharField(default="Wofost72_WLP_CWB", max_length=128),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("running", "Running"),
|
||||||
|
("success", "Success"),
|
||||||
|
("failure", "Failure"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="pending",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("input_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("result_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("error_message", models.TextField(blank=True, default="")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
"verbose_name": "Simulation Scenario",
|
||||||
|
"verbose_name_plural": "Simulation Scenarios",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SimulationRun",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("run_key", models.CharField(max_length=64)),
|
||||||
|
("label", models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("running", "Running"),
|
||||||
|
("success", "Success"),
|
||||||
|
("failure", "Failure"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="pending",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("weather_payload", models.JSONField(blank=True, default=list)),
|
||||||
|
("soil_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("crop_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("site_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("agromanagement_payload", models.JSONField(blank=True, default=list)),
|
||||||
|
("result_payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("error_message", models.TextField(blank=True, default="")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"scenario",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=models.deletion.CASCADE,
|
||||||
|
related_name="runs",
|
||||||
|
to="crop_simulation.simulationscenario",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["scenario_id", "id"],
|
||||||
|
"verbose_name": "Simulation Run",
|
||||||
|
"verbose_name_plural": "Simulation Runs",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="simulationrun",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("scenario", "run_key"),
|
||||||
|
name="crop_simulation_unique_run_key_per_scenario",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationScenario(models.Model):
|
||||||
|
class ScenarioType(models.TextChoices):
|
||||||
|
SINGLE = "single", "Single Simulation"
|
||||||
|
CROP_COMPARISON = "crop_comparison", "Crop Comparison"
|
||||||
|
FERTILIZATION_COMPARISON = (
|
||||||
|
"fertilization_comparison",
|
||||||
|
"Fertilization Comparison",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
RUNNING = "running", "Running"
|
||||||
|
SUCCESS = "success", "Success"
|
||||||
|
FAILURE = "failure", "Failure"
|
||||||
|
|
||||||
|
name = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
scenario_type = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
choices=ScenarioType.choices,
|
||||||
|
default=ScenarioType.SINGLE,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
model_name = models.CharField(max_length=128, default="Wofost72_WLP_CWB")
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=Status.choices,
|
||||||
|
default=Status.PENDING,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
input_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
result_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
error_message = models.TextField(blank=True, default="")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
verbose_name = "Simulation Scenario"
|
||||||
|
verbose_name_plural = "Simulation Scenarios"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name or f"{self.scenario_type}:{self.pk}"
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationRun(models.Model):
|
||||||
|
scenario = models.ForeignKey(
|
||||||
|
SimulationScenario,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="runs",
|
||||||
|
)
|
||||||
|
run_key = models.CharField(max_length=64)
|
||||||
|
label = models.CharField(max_length=255)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=SimulationScenario.Status.choices,
|
||||||
|
default=SimulationScenario.Status.PENDING,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
weather_payload = models.JSONField(default=list, blank=True)
|
||||||
|
soil_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
crop_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
site_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
agromanagement_payload = models.JSONField(default=list, blank=True)
|
||||||
|
result_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
error_message = models.TextField(blank=True, default="")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["scenario_id", "id"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["scenario", "run_key"],
|
||||||
|
name="crop_simulation_unique_run_key_per_scenario",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
verbose_name = "Simulation Run"
|
||||||
|
verbose_name_plural = "Simulation Runs"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.scenario_id}:{self.run_key}"
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
class CropSimulationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _json_ready(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): _json_ready(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_json_ready(item) for item in value]
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return [_json_ready(item) for item in value]
|
||||||
|
if isinstance(value, (date, datetime)):
|
||||||
|
return value.isoformat()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_date(value: Any) -> date:
|
||||||
|
if isinstance(value, date) and not isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date()
|
||||||
|
if isinstance(value, str):
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
raise CropSimulationError(f"Unsupported date value: {value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_weather_records(weather: Any) -> list[dict[str, Any]]:
|
||||||
|
if isinstance(weather, dict):
|
||||||
|
if "records" in weather:
|
||||||
|
records = weather["records"]
|
||||||
|
else:
|
||||||
|
records = [weather]
|
||||||
|
else:
|
||||||
|
records = weather
|
||||||
|
|
||||||
|
if not isinstance(records, list) or not records:
|
||||||
|
raise CropSimulationError("Weather input must contain at least one record.")
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
for raw in records:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise CropSimulationError("Weather records must be dictionaries.")
|
||||||
|
current_date = _coerce_date(raw.get("DAY") or raw.get("day"))
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"DAY": current_date,
|
||||||
|
"LAT": float(raw.get("LAT", raw.get("lat", 0.0))),
|
||||||
|
"LON": float(raw.get("LON", raw.get("lon", 0.0))),
|
||||||
|
"ELEV": float(raw.get("ELEV", raw.get("elev", 0.0))),
|
||||||
|
"IRRAD": float(raw.get("IRRAD", raw.get("irrad", 15_000_000.0))),
|
||||||
|
"TMIN": float(raw.get("TMIN", raw.get("tmin", 10.0))),
|
||||||
|
"TMAX": float(raw.get("TMAX", raw.get("tmax", 20.0))),
|
||||||
|
"VAP": float(raw.get("VAP", raw.get("vap", 12.0))),
|
||||||
|
"WIND": float(raw.get("WIND", raw.get("wind", 2.0))),
|
||||||
|
"RAIN": float(raw.get("RAIN", raw.get("rain", 0.0))),
|
||||||
|
"E0": float(raw.get("E0", raw.get("e0", 0.35))),
|
||||||
|
"ES0": float(raw.get("ES0", raw.get("es0", 0.3))),
|
||||||
|
"ET0": float(raw.get("ET0", raw.get("et0", 0.32))),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_agromanagement(agromanagement: Any) -> list[dict[str, Any]]:
|
||||||
|
if isinstance(agromanagement, dict) and "AgroManagement" in agromanagement:
|
||||||
|
campaigns = agromanagement["AgroManagement"]
|
||||||
|
elif isinstance(agromanagement, list):
|
||||||
|
campaigns = agromanagement
|
||||||
|
elif isinstance(agromanagement, dict):
|
||||||
|
campaigns = [agromanagement]
|
||||||
|
else:
|
||||||
|
raise CropSimulationError("Agromanagement input must be a dict or list.")
|
||||||
|
|
||||||
|
if not campaigns:
|
||||||
|
raise CropSimulationError("Agromanagement input cannot be empty.")
|
||||||
|
return campaigns
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_pcse_output_records(records: Any) -> list[dict[str, Any]]:
|
||||||
|
if records is None:
|
||||||
|
return []
|
||||||
|
if isinstance(records, dict):
|
||||||
|
return [records]
|
||||||
|
if isinstance(records, list):
|
||||||
|
return records
|
||||||
|
if isinstance(records, tuple):
|
||||||
|
return list(records)
|
||||||
|
return [records]
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_first_not_none(*values: Any) -> Any:
|
||||||
|
for value in values:
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_total_n(agromanagement: list[dict[str, Any]]) -> float:
|
||||||
|
total_n = 0.0
|
||||||
|
for campaign in agromanagement:
|
||||||
|
if not isinstance(campaign, dict):
|
||||||
|
continue
|
||||||
|
for payload in campaign.values():
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
for bucket_name in ("TimedEvents", "StateEvents"):
|
||||||
|
for event_group in 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):
|
||||||
|
continue
|
||||||
|
for event_payload in event.values():
|
||||||
|
if isinstance(event_payload, dict):
|
||||||
|
total_n += float(event_payload.get("N_amount", 0.0))
|
||||||
|
return total_n
|
||||||
|
|
||||||
|
|
||||||
|
def _load_pcse_bindings() -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
base_module = importlib.import_module("pcse.base")
|
||||||
|
models_module = importlib.import_module("pcse.models")
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parameter_provider = getattr(base_module, "ParameterProvider", None)
|
||||||
|
weather_provider = getattr(base_module, "WeatherDataProvider", object)
|
||||||
|
weather_container = getattr(base_module, "WeatherDataContainer", None)
|
||||||
|
if weather_container is None or parameter_provider is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ParameterProvider": parameter_provider,
|
||||||
|
"WeatherDataProvider": weather_provider,
|
||||||
|
"WeatherDataContainer": weather_container,
|
||||||
|
"models": models_module,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_model_class(bindings: dict[str, Any], model_name: str):
|
||||||
|
models_source = bindings["models"]
|
||||||
|
if isinstance(models_source, dict):
|
||||||
|
return models_source[model_name]
|
||||||
|
return getattr(models_source, model_name)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PreparedSimulationInput:
|
||||||
|
weather: list[dict[str, Any]]
|
||||||
|
soil: dict[str, Any]
|
||||||
|
crop: dict[str, Any]
|
||||||
|
site: dict[str, Any]
|
||||||
|
agromanagement: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class PcseSimulationManager:
|
||||||
|
def __init__(self, model_name: str = "Wofost72_WLP_CWB"):
|
||||||
|
self.model_name = model_name
|
||||||
|
|
||||||
|
def run_simulation(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
weather: Any,
|
||||||
|
soil: dict[str, Any],
|
||||||
|
crop_parameters: dict[str, Any],
|
||||||
|
agromanagement: Any,
|
||||||
|
site_parameters: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
prepared = PreparedSimulationInput(
|
||||||
|
weather=_normalize_weather_records(weather),
|
||||||
|
soil=soil or {},
|
||||||
|
crop=crop_parameters or {},
|
||||||
|
site=site_parameters or {},
|
||||||
|
agromanagement=_normalize_agromanagement(agromanagement),
|
||||||
|
)
|
||||||
|
bindings = _load_pcse_bindings()
|
||||||
|
if bindings is None:
|
||||||
|
raise CropSimulationError(
|
||||||
|
"PCSE is not installed or required PCSE classes could not be loaded."
|
||||||
|
)
|
||||||
|
return self._run_with_pcse(prepared, bindings)
|
||||||
|
|
||||||
|
def _run_with_pcse(
|
||||||
|
self,
|
||||||
|
prepared: PreparedSimulationInput,
|
||||||
|
bindings: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
weather_provider_base = bindings["WeatherDataProvider"]
|
||||||
|
weather_container = bindings["WeatherDataContainer"]
|
||||||
|
parameter_provider_cls = bindings["ParameterProvider"]
|
||||||
|
model_cls = _resolve_model_class(bindings, self.model_name)
|
||||||
|
|
||||||
|
class DictWeatherProvider(weather_provider_base):
|
||||||
|
def __init__(self, records: list[dict[str, Any]]):
|
||||||
|
super().__init__()
|
||||||
|
self._records = {
|
||||||
|
item["DAY"]: weather_container(**item)
|
||||||
|
for item in records
|
||||||
|
}
|
||||||
|
|
||||||
|
def __call__(self, day):
|
||||||
|
return self._records[_coerce_date(day)]
|
||||||
|
|
||||||
|
parameter_provider = parameter_provider_cls(
|
||||||
|
cropdata=prepared.crop,
|
||||||
|
soildata=prepared.soil,
|
||||||
|
sitedata=prepared.site,
|
||||||
|
)
|
||||||
|
simulation = model_cls(
|
||||||
|
parameterprovider=parameter_provider,
|
||||||
|
weatherdataprovider=DictWeatherProvider(prepared.weather),
|
||||||
|
agromanagement=prepared.agromanagement,
|
||||||
|
output_vars=DEFAULT_OUTPUT_VARS,
|
||||||
|
summary_vars=DEFAULT_SUMMARY_VARS,
|
||||||
|
terminal_vars=DEFAULT_TERMINAL_VARS,
|
||||||
|
)
|
||||||
|
if hasattr(simulation, "run_till_terminate"):
|
||||||
|
simulation.run_till_terminate()
|
||||||
|
elif hasattr(simulation, "run"):
|
||||||
|
simulation.run(days=len(prepared.weather))
|
||||||
|
else:
|
||||||
|
raise CropSimulationError("PCSE model does not expose a runnable interface.")
|
||||||
|
|
||||||
|
daily_output = _normalize_pcse_output_records(simulation.get_output())
|
||||||
|
summary_output = _normalize_pcse_output_records(simulation.get_summary_output())
|
||||||
|
terminal_output = _normalize_pcse_output_records(simulation.get_terminal_output())
|
||||||
|
return self._build_result(
|
||||||
|
engine="pcse",
|
||||||
|
daily_output=daily_output,
|
||||||
|
summary_output=summary_output,
|
||||||
|
terminal_output=terminal_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_result(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
engine: str,
|
||||||
|
daily_output: list[dict[str, Any]],
|
||||||
|
summary_output: list[dict[str, Any]],
|
||||||
|
terminal_output: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
terminal = terminal_output[-1] if terminal_output else {}
|
||||||
|
summary = summary_output[-1] if summary_output else {}
|
||||||
|
final_daily = daily_output[-1] if daily_output else {}
|
||||||
|
metrics = {
|
||||||
|
"yield_estimate": _pick_first_not_none(
|
||||||
|
terminal.get("TWSO"),
|
||||||
|
summary.get("TWSO"),
|
||||||
|
final_daily.get("TWSO"),
|
||||||
|
),
|
||||||
|
"biomass": _pick_first_not_none(
|
||||||
|
terminal.get("TAGP"),
|
||||||
|
summary.get("TAGP"),
|
||||||
|
final_daily.get("TAGP"),
|
||||||
|
),
|
||||||
|
"max_lai": _pick_first_not_none(
|
||||||
|
terminal.get("LAI"),
|
||||||
|
summary.get("LAIMAX"),
|
||||||
|
final_daily.get("LAI"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"engine": engine,
|
||||||
|
"model_name": self.model_name,
|
||||||
|
"metrics": _json_ready(metrics),
|
||||||
|
"daily_output": _json_ready(daily_output),
|
||||||
|
"summary_output": _json_ready(summary_output),
|
||||||
|
"terminal_output": _json_ready(terminal_output),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CropSimulationService:
|
||||||
|
def __init__(self, manager: PcseSimulationManager | None = None):
|
||||||
|
self.manager = manager or PcseSimulationManager()
|
||||||
|
|
||||||
|
def run_single_simulation(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
weather: Any,
|
||||||
|
soil: dict[str, Any],
|
||||||
|
crop_parameters: dict[str, Any],
|
||||||
|
agromanagement: Any,
|
||||||
|
site_parameters: dict[str, Any] | None = None,
|
||||||
|
name: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
scenario = SimulationScenario.objects.create(
|
||||||
|
name=name,
|
||||||
|
scenario_type=SimulationScenario.ScenarioType.SINGLE,
|
||||||
|
model_name=self.manager.model_name,
|
||||||
|
input_payload=_json_ready(
|
||||||
|
{
|
||||||
|
"weather": weather,
|
||||||
|
"soil": soil,
|
||||||
|
"crop_parameters": crop_parameters,
|
||||||
|
"site_parameters": site_parameters or {},
|
||||||
|
"agromanagement": agromanagement,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
run = SimulationRun.objects.create(
|
||||||
|
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 {}),
|
||||||
|
agromanagement_payload=_json_ready(agromanagement),
|
||||||
|
)
|
||||||
|
return self._execute_scenario(
|
||||||
|
scenario=scenario,
|
||||||
|
run_specs=[
|
||||||
|
{
|
||||||
|
"instance": run,
|
||||||
|
"weather": weather,
|
||||||
|
"soil": soil,
|
||||||
|
"crop_parameters": crop_parameters,
|
||||||
|
"site_parameters": site_parameters or {},
|
||||||
|
"agromanagement": agromanagement,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def compare_crops(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
weather: Any,
|
||||||
|
soil: dict[str, Any],
|
||||||
|
crop_a: dict[str, Any],
|
||||||
|
crop_b: dict[str, Any],
|
||||||
|
agromanagement: Any,
|
||||||
|
site_parameters: dict[str, Any] | None = None,
|
||||||
|
name: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
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,
|
||||||
|
"crop_a": crop_a,
|
||||||
|
"crop_b": crop_b,
|
||||||
|
"site_parameters": site_parameters or {},
|
||||||
|
"agromanagement": agromanagement,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
runs = [
|
||||||
|
SimulationRun.objects.create(
|
||||||
|
scenario=scenario,
|
||||||
|
run_key="crop_a",
|
||||||
|
label=crop_a.get("crop_name", "crop_a"),
|
||||||
|
weather_payload=_json_ready(weather),
|
||||||
|
soil_payload=_json_ready(soil),
|
||||||
|
crop_payload=_json_ready(crop_a),
|
||||||
|
site_payload=_json_ready(site_parameters or {}),
|
||||||
|
agromanagement_payload=_json_ready(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),
|
||||||
|
crop_payload=_json_ready(crop_b),
|
||||||
|
site_payload=_json_ready(site_parameters or {}),
|
||||||
|
agromanagement_payload=_json_ready(agromanagement),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return self._execute_scenario(
|
||||||
|
scenario=scenario,
|
||||||
|
run_specs=[
|
||||||
|
{
|
||||||
|
"instance": runs[0],
|
||||||
|
"weather": weather,
|
||||||
|
"soil": soil,
|
||||||
|
"crop_parameters": crop_a,
|
||||||
|
"site_parameters": site_parameters or {},
|
||||||
|
"agromanagement": agromanagement,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"instance": runs[1],
|
||||||
|
"weather": weather,
|
||||||
|
"soil": soil,
|
||||||
|
"crop_parameters": crop_b,
|
||||||
|
"site_parameters": site_parameters or {},
|
||||||
|
"agromanagement": agromanagement,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def compare_fertilization_strategies(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
weather: Any,
|
||||||
|
soil: dict[str, Any],
|
||||||
|
crop_parameters: dict[str, Any],
|
||||||
|
strategies: list[dict[str, Any]],
|
||||||
|
site_parameters: dict[str, Any] | None = None,
|
||||||
|
name: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if len(strategies) < 2:
|
||||||
|
raise CropSimulationError("At least two fertilization strategies are required.")
|
||||||
|
|
||||||
|
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 {},
|
||||||
|
"strategies": strategies,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
run_specs = []
|
||||||
|
for index, strategy in enumerate(strategies, start=1):
|
||||||
|
run = SimulationRun.objects.create(
|
||||||
|
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 {}),
|
||||||
|
agromanagement_payload=_json_ready(strategy["agromanagement"]),
|
||||||
|
)
|
||||||
|
run_specs.append(
|
||||||
|
{
|
||||||
|
"instance": run,
|
||||||
|
"weather": weather,
|
||||||
|
"soil": soil,
|
||||||
|
"crop_parameters": crop_parameters,
|
||||||
|
"site_parameters": site_parameters or {},
|
||||||
|
"agromanagement": strategy["agromanagement"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self._execute_scenario(scenario=scenario, run_specs=run_specs)
|
||||||
|
|
||||||
|
def get_scenario_result(self, scenario_id: int) -> dict[str, Any]:
|
||||||
|
scenario = SimulationScenario.objects.prefetch_related("runs").get(pk=scenario_id)
|
||||||
|
return {
|
||||||
|
"id": scenario.id,
|
||||||
|
"name": scenario.name,
|
||||||
|
"scenario_type": scenario.scenario_type,
|
||||||
|
"status": scenario.status,
|
||||||
|
"model_name": scenario.model_name,
|
||||||
|
"input_payload": scenario.input_payload,
|
||||||
|
"result_payload": scenario.result_payload,
|
||||||
|
"error_message": scenario.error_message,
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"id": run.id,
|
||||||
|
"run_key": run.run_key,
|
||||||
|
"label": run.label,
|
||||||
|
"status": run.status,
|
||||||
|
"result_payload": run.result_payload,
|
||||||
|
"error_message": run.error_message,
|
||||||
|
}
|
||||||
|
for run in scenario.runs.all()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _execute_scenario(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
scenario: SimulationScenario,
|
||||||
|
run_specs: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
scenario.status = SimulationScenario.Status.RUNNING
|
||||||
|
scenario.error_message = ""
|
||||||
|
scenario.save(update_fields=["status", "error_message", "updated_at"])
|
||||||
|
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
for spec in run_specs:
|
||||||
|
run = spec["instance"]
|
||||||
|
run.status = SimulationScenario.Status.RUNNING
|
||||||
|
run.error_message = ""
|
||||||
|
run.save(update_fields=["status", "error_message", "updated_at"])
|
||||||
|
result = self.manager.run_simulation(
|
||||||
|
weather=spec["weather"],
|
||||||
|
soil=spec["soil"],
|
||||||
|
crop_parameters=spec["crop_parameters"],
|
||||||
|
agromanagement=spec["agromanagement"],
|
||||||
|
site_parameters=spec["site_parameters"],
|
||||||
|
)
|
||||||
|
run.status = SimulationScenario.Status.SUCCESS
|
||||||
|
run.result_payload = result
|
||||||
|
run.save(update_fields=["status", "result_payload", "updated_at"])
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"run_key": run.run_key,
|
||||||
|
"label": run.label,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
message = str(exc)
|
||||||
|
run = spec["instance"]
|
||||||
|
run.status = SimulationScenario.Status.FAILURE
|
||||||
|
run.error_message = message
|
||||||
|
run.save(update_fields=["status", "error_message", "updated_at"])
|
||||||
|
scenario.status = SimulationScenario.Status.FAILURE
|
||||||
|
scenario.error_message = message
|
||||||
|
scenario.result_payload = {"runs": results}
|
||||||
|
scenario.save(
|
||||||
|
update_fields=["status", "error_message", "result_payload", "updated_at"]
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
scenario_result = self._build_scenario_result(scenario, results)
|
||||||
|
scenario.status = SimulationScenario.Status.SUCCESS
|
||||||
|
scenario.result_payload = scenario_result
|
||||||
|
scenario.error_message = ""
|
||||||
|
scenario.save(
|
||||||
|
update_fields=["status", "result_payload", "error_message", "updated_at"]
|
||||||
|
)
|
||||||
|
return scenario_result
|
||||||
|
|
||||||
|
def _build_scenario_result(
|
||||||
|
self,
|
||||||
|
scenario: SimulationScenario,
|
||||||
|
results: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
|
"scenario_id": scenario.id,
|
||||||
|
"scenario_type": scenario.scenario_type,
|
||||||
|
"status": SimulationScenario.Status.SUCCESS,
|
||||||
|
"runs": results,
|
||||||
|
}
|
||||||
|
if scenario.scenario_type == SimulationScenario.ScenarioType.SINGLE:
|
||||||
|
payload["result"] = results[0]["result"]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
run_metrics = [
|
||||||
|
{
|
||||||
|
"run_key": item["run_key"],
|
||||||
|
"label": item["label"],
|
||||||
|
"yield_estimate": float(item["result"]["metrics"]["yield_estimate"] or 0.0),
|
||||||
|
"biomass": float(item["result"]["metrics"]["biomass"] or 0.0),
|
||||||
|
}
|
||||||
|
for item in results
|
||||||
|
]
|
||||||
|
best = max(run_metrics, key=lambda item: item["yield_estimate"])
|
||||||
|
payload["comparison"] = {
|
||||||
|
"best_run_key": best["run_key"],
|
||||||
|
"best_label": best["label"],
|
||||||
|
"best_yield_estimate": best["yield_estimate"],
|
||||||
|
"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 (
|
||||||
|
scenario.scenario_type
|
||||||
|
== SimulationScenario.ScenarioType.FERTILIZATION_COMPARISON
|
||||||
|
):
|
||||||
|
payload["recommendation"] = {
|
||||||
|
"recommended_run_key": best["run_key"],
|
||||||
|
"recommended_label": best["label"],
|
||||||
|
"expected_yield_estimate": best["yield_estimate"],
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def run_single_simulation(**kwargs) -> dict[str, Any]:
|
||||||
|
return CropSimulationService().run_single_simulation(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def compare_crops(**kwargs) -> dict[str, Any]:
|
||||||
|
return CropSimulationService().compare_crops(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def compare_fertilization_strategies(**kwargs) -> dict[str, Any]:
|
||||||
|
return CropSimulationService().compare_fertilization_strategies(**kwargs)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from collections import namedtuple
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from unittest import skipUnless
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from crop_simulation.services import CropSimulationService, PcseSimulationManager
|
||||||
|
|
||||||
|
|
||||||
|
@skipUnless(
|
||||||
|
importlib.util.find_spec("pcse") is not None,
|
||||||
|
"pcse must be installed to run the real WOFOST test.",
|
||||||
|
)
|
||||||
|
class CropSimulationSingleRunTest(TestCase):
|
||||||
|
def test_single_simulation_prints_response(self):
|
||||||
|
os.environ["HOME"] = tempfile.mkdtemp(prefix="pcse-home-", dir="/tmp")
|
||||||
|
from pcse import settings as pcse_settings
|
||||||
|
from pcse.tests.db_input import (
|
||||||
|
AgroManagementDataProvider,
|
||||||
|
GridWeatherDataProvider,
|
||||||
|
fetch_cropdata,
|
||||||
|
fetch_sitedata,
|
||||||
|
fetch_soildata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def namedtuple_factory(cursor, row):
|
||||||
|
fields = [column[0] for column in cursor.description]
|
||||||
|
cls = namedtuple("Row", fields)
|
||||||
|
return cls._make(row)
|
||||||
|
|
||||||
|
db_path = os.path.join(pcse_settings.PCSE_USER_HOME, "pcse.db")
|
||||||
|
connection = sqlite3.connect(db_path)
|
||||||
|
connection.row_factory = namedtuple_factory
|
||||||
|
|
||||||
|
grid = int(os.environ.get("PCSE_TEST_GRID", "31031"))
|
||||||
|
crop_no = int(os.environ.get("PCSE_TEST_CROP_NO", "1"))
|
||||||
|
year = int(os.environ.get("PCSE_TEST_YEAR", "2000"))
|
||||||
|
|
||||||
|
weather = GridWeatherDataProvider(connection, grid_no=grid).export()
|
||||||
|
soil = fetch_soildata(connection, grid)
|
||||||
|
site = fetch_sitedata(connection, grid, year)
|
||||||
|
crop_parameters = fetch_cropdata(connection, grid, year, crop_no)
|
||||||
|
agromanagement = AgroManagementDataProvider(connection, grid, crop_no, year)
|
||||||
|
|
||||||
|
response = CropSimulationService(
|
||||||
|
manager=PcseSimulationManager(model_name="Wofost72_WLP_CWB")
|
||||||
|
).run_single_simulation(
|
||||||
|
weather=weather,
|
||||||
|
soil=soil,
|
||||||
|
crop_parameters=crop_parameters,
|
||||||
|
agromanagement=agromanagement,
|
||||||
|
site_parameters=site,
|
||||||
|
name="single real wofost run",
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.close()
|
||||||
|
print("\nCrop Simulation Response:\n", response)
|
||||||
|
self.assertEqual(response["result"]["engine"], "pcse")
|
||||||
|
self.assertIn("yield_estimate", response["result"]["metrics"])
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from collections import namedtuple
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
from unittest import skipUnless
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from .models import SimulationRun, SimulationScenario
|
||||||
|
from .services import CropSimulationService, CropSimulationError, PcseSimulationManager
|
||||||
|
|
||||||
|
|
||||||
|
def build_weather(days: int = 5) -> list[dict]:
|
||||||
|
start = date(2026, 4, 1)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"DAY": start + timedelta(days=index),
|
||||||
|
"LAT": 35.7,
|
||||||
|
"LON": 51.4,
|
||||||
|
"ELEV": 1200,
|
||||||
|
"IRRAD": 16_000_000 + (index * 100_000),
|
||||||
|
"TMIN": 11 + index,
|
||||||
|
"TMAX": 22 + index,
|
||||||
|
"VAP": 12,
|
||||||
|
"WIND": 2.4,
|
||||||
|
"RAIN": 0.8,
|
||||||
|
"E0": 0.35,
|
||||||
|
"ES0": 0.3,
|
||||||
|
"ET0": 0.32,
|
||||||
|
}
|
||||||
|
for index in range(days)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_agromanagement(n_amount: float = 30.0) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
date(2026, 4, 1): {
|
||||||
|
"CropCalendar": {
|
||||||
|
"crop_name": "wheat",
|
||||||
|
"variety_name": "winter-wheat",
|
||||||
|
"crop_start_date": date(2026, 4, 5),
|
||||||
|
"crop_start_type": "sowing",
|
||||||
|
"crop_end_date": date(2026, 9, 1),
|
||||||
|
"crop_end_type": "harvest",
|
||||||
|
"max_duration": 180,
|
||||||
|
},
|
||||||
|
"TimedEvents": [
|
||||||
|
{
|
||||||
|
"event_signal": "apply_n",
|
||||||
|
"name": "N strategy",
|
||||||
|
"events_table": [
|
||||||
|
{
|
||||||
|
date(2026, 4, 20): {
|
||||||
|
"N_amount": n_amount,
|
||||||
|
"N_recovery": 0.7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"StateEvents": [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CropSimulationServiceTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.service = CropSimulationService()
|
||||||
|
self.weather = build_weather()
|
||||||
|
self.soil = {"SMFCF": 0.34, "SMW": 0.12, "RDMSOL": 120.0}
|
||||||
|
self.site = {"WAV": 40.0}
|
||||||
|
self.crop = {"crop_name": "wheat", "TSUM1": 800, "YIELD_SCALE": 1.0}
|
||||||
|
|
||||||
|
def test_failure_marks_scenario_and_run_failed(self):
|
||||||
|
with patch.object(
|
||||||
|
self.service.manager,
|
||||||
|
"run_simulation",
|
||||||
|
side_effect=CropSimulationError("pcse failed"),
|
||||||
|
):
|
||||||
|
with self.assertRaises(CropSimulationError):
|
||||||
|
self.service.run_single_simulation(
|
||||||
|
weather=self.weather,
|
||||||
|
soil=self.soil,
|
||||||
|
crop_parameters=self.crop,
|
||||||
|
agromanagement=build_agromanagement(),
|
||||||
|
site_parameters=self.site,
|
||||||
|
name="broken run",
|
||||||
|
)
|
||||||
|
|
||||||
|
scenario = SimulationScenario.objects.get()
|
||||||
|
run = SimulationRun.objects.get()
|
||||||
|
|
||||||
|
self.assertEqual(scenario.status, SimulationScenario.Status.FAILURE)
|
||||||
|
self.assertEqual(run.status, SimulationScenario.Status.FAILURE)
|
||||||
|
self.assertEqual(scenario.error_message, "pcse failed")
|
||||||
|
|
||||||
|
def test_requires_at_least_two_fertilization_strategies(self):
|
||||||
|
with self.assertRaises(CropSimulationError):
|
||||||
|
self.service.compare_fertilization_strategies(
|
||||||
|
weather=self.weather,
|
||||||
|
soil=self.soil,
|
||||||
|
crop_parameters=self.crop,
|
||||||
|
strategies=[{"label": "only", "agromanagement": build_agromanagement()}],
|
||||||
|
site_parameters=self.site,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_raises_clear_error_when_pcse_is_unavailable(self):
|
||||||
|
with patch("crop_simulation.services._load_pcse_bindings", return_value=None):
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
CropSimulationError,
|
||||||
|
"PCSE is not installed or required PCSE classes could not be loaded.",
|
||||||
|
):
|
||||||
|
self.service.run_single_simulation(
|
||||||
|
weather=self.weather,
|
||||||
|
soil=self.soil,
|
||||||
|
crop_parameters=self.crop,
|
||||||
|
agromanagement=build_agromanagement(),
|
||||||
|
site_parameters=self.site,
|
||||||
|
name="missing pcse",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@skipUnless(
|
||||||
|
importlib.util.find_spec("pcse") is not None,
|
||||||
|
"pcse must be installed to run real WOFOST integration tests.",
|
||||||
|
)
|
||||||
|
class CropSimulationPcseIntegrationTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
os.environ["HOME"] = tempfile.mkdtemp(prefix="pcse-home-", dir="/tmp")
|
||||||
|
from pcse import settings as pcse_settings
|
||||||
|
from pcse.tests.db_input import (
|
||||||
|
AgroManagementDataProvider,
|
||||||
|
GridWeatherDataProvider,
|
||||||
|
fetch_cropdata,
|
||||||
|
fetch_sitedata,
|
||||||
|
fetch_soildata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def namedtuple_factory(cursor, row):
|
||||||
|
fields = [column[0] for column in cursor.description]
|
||||||
|
cls = namedtuple("Row", fields)
|
||||||
|
return cls._make(row)
|
||||||
|
|
||||||
|
self.grid = int(os.environ.get("PCSE_TEST_GRID", "31031"))
|
||||||
|
self.crop_no = int(os.environ.get("PCSE_TEST_CROP_NO", "1"))
|
||||||
|
self.year = int(os.environ.get("PCSE_TEST_YEAR", "2000"))
|
||||||
|
|
||||||
|
self.connection = sqlite3.connect(
|
||||||
|
os.path.join(pcse_settings.PCSE_USER_HOME, "pcse.db")
|
||||||
|
)
|
||||||
|
self.connection.row_factory = namedtuple_factory
|
||||||
|
|
||||||
|
self.weather = GridWeatherDataProvider(
|
||||||
|
self.connection,
|
||||||
|
grid_no=self.grid,
|
||||||
|
).export()
|
||||||
|
self.soil = fetch_soildata(self.connection, self.grid)
|
||||||
|
self.site = fetch_sitedata(self.connection, self.grid, self.year)
|
||||||
|
self.crop = fetch_cropdata(
|
||||||
|
self.connection,
|
||||||
|
self.grid,
|
||||||
|
self.year,
|
||||||
|
self.crop_no,
|
||||||
|
)
|
||||||
|
self.agromanagement = AgroManagementDataProvider(
|
||||||
|
self.connection,
|
||||||
|
self.grid,
|
||||||
|
self.crop_no,
|
||||||
|
self.year,
|
||||||
|
)
|
||||||
|
self.service = CropSimulationService(
|
||||||
|
manager=PcseSimulationManager(model_name="Wofost72_WLP_CWB")
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.connection.close()
|
||||||
|
|
||||||
|
def test_real_wofost_execute_full_service_path(self):
|
||||||
|
result = self.service.run_single_simulation(
|
||||||
|
weather=self.weather,
|
||||||
|
soil=self.soil,
|
||||||
|
crop_parameters=self.crop,
|
||||||
|
agromanagement=self.agromanagement,
|
||||||
|
site_parameters=self.site,
|
||||||
|
name="pcse path",
|
||||||
|
)
|
||||||
|
|
||||||
|
scenario = SimulationScenario.objects.get()
|
||||||
|
|
||||||
|
self.assertEqual(scenario.status, SimulationScenario.Status.SUCCESS)
|
||||||
|
self.assertEqual(result["result"]["engine"], "pcse")
|
||||||
|
self.assertIsNotNone(result["result"]["metrics"]["yield_estimate"])
|
||||||
|
self.assertIsNotNone(result["result"]["metrics"]["biomass"])
|
||||||
+8105
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,12 @@ Adapter Pattern برای API providers — سوئیچ بین GapGPT، Avalai و
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
|
except ImportError: # pragma: no cover - optional for stripped test envs
|
||||||
|
class OpenAI: # type: ignore[override]
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
raise ImportError("openai package is required for RAG clients.")
|
||||||
|
|
||||||
from .config import RAGConfig, load_rag_config
|
from .config import RAGConfig, load_rag_config
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ openai>=1.0,<1.40
|
|||||||
|
|
||||||
# === NumPy (pinned for Python 3.10 compatibility) ===
|
# === NumPy (pinned for Python 3.10 compatibility) ===
|
||||||
numpy>=1.23,<1.27
|
numpy>=1.23,<1.27
|
||||||
|
pcse
|
||||||
|
|
||||||
# === Vector Databases ===
|
# === Vector Databases ===
|
||||||
chromadb>=0.4.24,<0.5
|
chromadb>=0.4.24,<0.5
|
||||||
|
|||||||
Reference in New Issue
Block a user