Files

468 lines
18 KiB
Python
Raw Permalink Normal View History

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
2026-05-13 16:45:54 +03:30
from farm_data.models import PlantCatalogSnapshot, SensorData
2026-05-13 22:28:56 +03:30
from farm_data.services import assign_farm_plants_from_backend_ids
2026-05-13 16:45:54 +03:30
from irrigation.models import IrrigationMethod
from location_data.models import SoilLocation
from weather.models import WeatherForecast
2026-04-24 17:40:25 +03:30
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"])
2026-05-13 16:45:54 +03:30
class CropSimulationCanonicalSnapshotTests(TestCase):
def setUp(self):
self.location = SoilLocation.objects.create(latitude="35.700000", longitude="51.400000")
self.weather = WeatherForecast.objects.create(
location=self.location,
forecast_date=date(2026, 4, 10),
temperature_min=12.0,
temperature_max=24.0,
temperature_mean=18.0,
humidity_mean=55.0,
precipitation=1.0,
et0=3.5,
)
self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=401, name="wheat")
self.irrigation_method = IrrigationMethod.objects.create(name="drip")
self.farm = SensorData.objects.create(
farm_uuid="550e8400-e29b-41d4-a716-446655440000",
center_location=self.location,
weather_forecast=self.weather,
irrigation_method=self.irrigation_method,
)
2026-05-13 22:28:56 +03:30
assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id])
2026-05-13 16:45:54 +03:30
@patch("crop_simulation.services.build_ai_farm_snapshot")
def test_build_simulation_payload_from_farm_uses_aggregated_metrics(self, mock_snapshot):
from crop_simulation.services import build_simulation_payload_from_farm
mock_snapshot.return_value = {
"farm_uuid": str(self.farm.farm_uuid),
"farm_metrics": {
"resolved_metrics": {
"soil_moisture": 36.0,
"ndwi": 0.31,
"nitrogen": 21.0,
"phosphorus": 11.0,
"potassium": 17.0,
"soil_ph": 6.8,
"electrical_conductivity": 1.4,
}
},
"source_metadata": {
"farm_metrics": {
"canonical_source": "farmer_block_aggregated_snapshot",
"aggregation_strategy": "farmer_block_mean",
}
},
}
payload = build_simulation_payload_from_farm(farm_uuid=str(self.farm.farm_uuid), plant_name="wheat")
self.assertEqual(payload["soil"]["soil_moisture"], 36.0)
self.assertEqual(payload["site_parameters"]["NAVAILI"], 21.0)
self.assertEqual(payload["soil"]["phosphorus"], 11.0)
self.assertEqual(payload["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
@patch("crop_simulation.services.build_ai_farm_snapshot")
def test_build_simulation_payload_from_farm_handles_missing_block_metrics(self, mock_snapshot):
from crop_simulation.services import build_simulation_payload_from_farm
mock_snapshot.return_value = {
"farm_uuid": str(self.farm.farm_uuid),
"farm_metrics": {"resolved_metrics": {}},
"source_metadata": {"farm_metrics": {"status": "missing"}},
}
payload = build_simulation_payload_from_farm(farm_uuid=str(self.farm.farm_uuid), plant_name="wheat")
self.assertEqual(payload["site_parameters"]["WAV"], 40.0)
self.assertEqual(payload["source_metadata"]["farm_metrics"]["status"], "missing")
@patch("crop_simulation.services.build_ai_farm_snapshot")
def test_run_single_simulation_stores_weather_provenance(self, mock_snapshot):
mock_snapshot.return_value = {
"farm_uuid": str(self.farm.farm_uuid),
"farm_metrics": {"resolved_metrics": {"soil_moisture": 35.0, "ndwi": 0.3}},
"source_metadata": {
"farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"},
"weather": {"policy": "center_location_latest_forecast"},
},
}
service = CropSimulationService()
with patch.object(service.manager, "run_simulation", return_value={"engine": "pcse", "metrics": {}, "daily_output": [], "summary_output": [], "terminal_output": []}):
result = service.run_single_simulation(
farm_uuid=str(self.farm.farm_uuid),
plant_name="wheat",
agromanagement=build_agromanagement(),
)
self.assertEqual(result["result"]["source_metadata"]["weather"]["policy"], "center_location_latest_forecast")
run = SimulationRun.objects.get()
self.assertEqual(run.result_payload["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")