2026-04-24 17:40:25 +03:30
|
|
|
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
|
2026-05-05 21:02:12 +03:30
|
|
|
from rest_framework.test import APIRequestFactory
|
2026-04-24 17:40:25 +03:30
|
|
|
|
|
|
|
|
from .models import SimulationRun, SimulationScenario
|
|
|
|
|
from .services import CropSimulationService, CropSimulationError, PcseSimulationManager
|
2026-05-05 21:02:12 +03:30
|
|
|
from .views import PlantGrowthSimulationView
|
2026-04-24 17:40:25 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-05 21:02:12 +03:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CropSimulationViewContractTests(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.factory = APIRequestFactory()
|
|
|
|
|
|
|
|
|
|
@patch("crop_simulation.views.run_growth_simulation_task.delay")
|
|
|
|
|
def test_growth_queue_response_includes_live_ai_metadata(self, mock_delay):
|
|
|
|
|
mock_delay.return_value.id = "task-123"
|
|
|
|
|
request = self.factory.post(
|
|
|
|
|
"/api/crop-simulation/growth/",
|
|
|
|
|
{
|
|
|
|
|
"plant_name": "wheat",
|
|
|
|
|
"dynamic_parameters": ["DVS"],
|
|
|
|
|
"weather": [
|
|
|
|
|
{
|
|
|
|
|
"DAY": "2026-04-01",
|
|
|
|
|
"LAT": 35.7,
|
|
|
|
|
"LON": 51.4,
|
|
|
|
|
"TMIN": 12,
|
|
|
|
|
"TMAX": 24,
|
|
|
|
|
"RAIN": 0.0,
|
|
|
|
|
"ET0": 0.32,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"soil_parameters": {"SMFCF": 0.34, "SMW": 0.14, "RDMSOL": 120.0},
|
|
|
|
|
"site_parameters": {"WAV": 40.0},
|
|
|
|
|
"agromanagement": [
|
|
|
|
|
{
|
|
|
|
|
"2026-04-01": {
|
|
|
|
|
"CropCalendar": {
|
|
|
|
|
"crop_name": "wheat",
|
|
|
|
|
"variety_name": "winter-wheat",
|
|
|
|
|
"crop_start_date": "2026-04-05",
|
|
|
|
|
"crop_start_type": "sowing",
|
|
|
|
|
"crop_end_date": "2026-09-01",
|
|
|
|
|
"crop_end_type": "harvest",
|
|
|
|
|
"max_duration": 180,
|
|
|
|
|
},
|
|
|
|
|
"TimedEvents": [],
|
|
|
|
|
"StateEvents": [],
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
format="json",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = PlantGrowthSimulationView.as_view()(request)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(response.status_code, 202)
|
|
|
|
|
self.assertEqual(response.data["meta"]["flow_type"], "live_ai_inference")
|
|
|
|
|
self.assertEqual(response.data["meta"]["source_service"], "ai_crop_simulation")
|
2026-04-24 18:34:17 +03:30
|
|
|
|
|
|
|
|
def test_recommend_best_crop_returns_best_candidate(self):
|
|
|
|
|
with patch.object(
|
|
|
|
|
self.service.manager,
|
|
|
|
|
"run_simulation",
|
|
|
|
|
side_effect=[
|
|
|
|
|
{
|
|
|
|
|
"engine": "pcse",
|
2026-04-24 22:20:15 +03:30
|
|
|
"model_name": "Wofost81_NWLP_CWB_CNB",
|
2026-04-24 18:34:17 +03:30
|
|
|
"metrics": {
|
|
|
|
|
"yield_estimate": 5200.0,
|
|
|
|
|
"biomass": 9800.0,
|
|
|
|
|
"max_lai": 4.1,
|
|
|
|
|
},
|
|
|
|
|
"daily_output": [],
|
|
|
|
|
"summary_output": [],
|
|
|
|
|
"terminal_output": [],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"engine": "pcse",
|
2026-04-24 22:20:15 +03:30
|
|
|
"model_name": "Wofost81_NWLP_CWB_CNB",
|
2026-04-24 18:34:17 +03:30
|
|
|
"metrics": {
|
|
|
|
|
"yield_estimate": 6100.0,
|
|
|
|
|
"biomass": 11000.0,
|
|
|
|
|
"max_lai": 4.4,
|
|
|
|
|
},
|
|
|
|
|
"daily_output": [],
|
|
|
|
|
"summary_output": [],
|
|
|
|
|
"terminal_output": [],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
):
|
|
|
|
|
result = self.service.recommend_best_crop(
|
|
|
|
|
weather=self.weather,
|
|
|
|
|
soil=self.soil,
|
|
|
|
|
crops=[
|
|
|
|
|
{"crop_name": "wheat", "label": "wheat", "TSUM1": 800},
|
|
|
|
|
{"crop_name": "maize", "label": "maize", "TSUM1": 900},
|
|
|
|
|
],
|
|
|
|
|
agromanagement=build_agromanagement(),
|
|
|
|
|
site_parameters=self.site,
|
|
|
|
|
name="best crop recommendation",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(result["recommended_crop"]["label"], "maize")
|
|
|
|
|
self.assertEqual(result["recommended_crop"]["expected_yield_estimate"], 6100.0)
|
|
|
|
|
self.assertEqual(len(result["candidates"]), 2)
|
|
|
|
|
|
|
|
|
|
def test_recommend_best_crop_requires_two_options(self):
|
|
|
|
|
with self.assertRaises(CropSimulationError):
|
|
|
|
|
self.service.recommend_best_crop(
|
|
|
|
|
weather=self.weather,
|
|
|
|
|
soil=self.soil,
|
|
|
|
|
crops=[{"crop_name": "wheat", "TSUM1": 800}],
|
|
|
|
|
agromanagement=build_agromanagement(),
|
|
|
|
|
site_parameters=self.site,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_run_single_simulation_merges_irrigation_and_fertilization_recommendations(self):
|
|
|
|
|
captured = {}
|
|
|
|
|
|
|
|
|
|
def fake_run_simulation(**kwargs):
|
|
|
|
|
captured.update(kwargs)
|
|
|
|
|
return {
|
|
|
|
|
"engine": "pcse",
|
2026-04-24 22:20:15 +03:30
|
|
|
"model_name": "Wofost81_NWLP_CWB_CNB",
|
2026-04-24 18:34:17 +03:30
|
|
|
"metrics": {
|
|
|
|
|
"yield_estimate": 5400.0,
|
|
|
|
|
"biomass": 9800.0,
|
|
|
|
|
"max_lai": 4.2,
|
|
|
|
|
},
|
|
|
|
|
"daily_output": [],
|
|
|
|
|
"summary_output": [],
|
|
|
|
|
"terminal_output": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with patch.object(self.service.manager, "run_simulation", side_effect=fake_run_simulation):
|
|
|
|
|
self.service.run_single_simulation(
|
|
|
|
|
weather=self.weather,
|
|
|
|
|
soil=self.soil,
|
|
|
|
|
crop_parameters=self.crop,
|
|
|
|
|
agromanagement=build_agromanagement(),
|
|
|
|
|
site_parameters=self.site,
|
|
|
|
|
irrigation_recommendation={
|
|
|
|
|
"events": [
|
|
|
|
|
{
|
|
|
|
|
"date": "2026-04-25",
|
|
|
|
|
"amount": 2.5,
|
|
|
|
|
"efficiency": 0.8,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
fertilization_recommendation={
|
|
|
|
|
"events": [
|
|
|
|
|
{
|
|
|
|
|
"date": "2026-04-20",
|
|
|
|
|
"N_amount": 45,
|
|
|
|
|
"N_recovery": 0.7,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
name="managed run",
|
2026-04-24 17:40:25 +03:30
|
|
|
)
|
|
|
|
|
|
2026-04-24 18:34:17 +03:30
|
|
|
timed_events = captured["agromanagement"][0][date(2026, 4, 1)]["TimedEvents"]
|
|
|
|
|
self.assertEqual(len(timed_events), 3)
|
|
|
|
|
self.assertEqual(timed_events[1]["event_signal"], "irrigate")
|
|
|
|
|
self.assertEqual(timed_events[1]["events_table"][0][date(2026, 4, 25)]["amount"], 2.5)
|
|
|
|
|
self.assertEqual(timed_events[2]["event_signal"], "apply_n")
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
timed_events[2]["events_table"][0][date(2026, 4, 20)]["N_amount"],
|
|
|
|
|
45.0,
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-24 17:40:25 +03:30
|
|
|
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"])
|