This commit is contained in:
2026-04-24 17:40:25 +03:30
parent f04a9fe71f
commit 24ed5776bc
16 changed files with 9244 additions and 11 deletions
+2
View File
@@ -60,3 +60,5 @@ htmlcov/
# OS
.DS_Store
Thumbs.db
logs/*
+4 -1
View File
@@ -1,3 +1,6 @@
from .celery import app as celery_app
try:
from .celery import app as celery_app
except ImportError: # pragma: no cover - fallback for test environments
celery_app = None
__all__ = ("celery_app",)
+22 -9
View File
@@ -1,7 +1,12 @@
import os
import importlib.util
from pathlib import Path
from dotenv import load_dotenv
try:
from dotenv import load_dotenv
except ImportError: # pragma: no cover - optional in stripped test envs
def load_dotenv():
return False
load_dotenv()
@@ -20,10 +25,6 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"corsheaders",
"drf_spectacular",
"drf_spectacular_sidecar",
"dashboard_data",
"rag",
"location_data",
@@ -32,11 +33,20 @@ INSTALLED_APPS = [
"plant",
"irrigation",
"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 = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@@ -45,6 +55,9 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
if importlib.util.find_spec("corsheaders"):
MIDDLEWARE.insert(1, "corsheaders.middleware.CorsMiddleware")
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
@@ -72,12 +85,12 @@ DATABASES = {
"PASSWORD": os.environ.get("DB_PASSWORD", ""),
"HOST": os.environ.get("DB_HOST", "127.0.0.1"),
"PORT": os.environ.get("DB_PORT", "3306"),
"OPTIONS": {
"charset": "utf8mb4",
},
}
}
if DATABASES["default"]["ENGINE"].endswith("mysql"):
DATABASES["default"]["OPTIONS"] = {"charset": "utf8mb4"}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
+10
View File
@@ -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"},
}
+11
View File
@@ -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),
]
+1
View File
@@ -0,0 +1 @@
+7
View File
@@ -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"
+125
View File
@@ -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",
),
),
]
+1
View File
@@ -0,0 +1 @@
+84
View File
@@ -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}"
+603
View File
@@ -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)
+63
View File
@@ -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"])
+199
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -4,7 +4,12 @@ Adapter Pattern برای API providers — سوئیچ بین GapGPT، Avalai و
import logging
import os
from openai import OpenAI
try:
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
+1
View File
@@ -28,6 +28,7 @@ openai>=1.0,<1.40
# === NumPy (pinned for Python 3.10 compatibility) ===
numpy>=1.23,<1.27
pcse
# === Vector Databases ===
chromadb>=0.4.24,<0.5