AI_Agro_Support/tests/test_disease.py
Arsham Mirehvandi ebc8ad26b6 Refactor pipeline to support multiple advice-enabled models per field and disease
- Updated the job structure to include `anmod_id`, allowing for multiple mathematical models to be processed for each field and disease combination.
- Enhanced the batch processing logic to discover and execute jobs for `(field, disease, anmod_id)` instead of just `(field, disease)`.
- Modified the output file naming convention to include `anmod_id`, ensuring unique output for each model.
- Adjusted database interactions to accommodate the new primary key structure based on `pathogen_model_id`.
- Updated relevant documentation in `README.md` to reflect changes in job discovery, execution, and output generation.
- Enhanced unit tests to validate the new functionality and ensure correct behavior across various scenarios.
2026-10-19 13:43:29 +02:00

220 lines
7.5 KiB
Python

"""Unit tests for INCUBAZPRIMARIA model_value rounding and labels."""
from __future__ import annotations
import unittest
from datetime import date
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import patch
from pipeline.errors import NoModelConfiguredError, PipelineError
from pipeline.stages.disease import (
_DayRecords,
_format_model_value,
_incub_label,
_resolve_day_fase,
find_advice_anmod_ids,
resolve_disease_forecast,
resolve_disease_forecast_by_anmod,
)
class FormatModelValueTests(unittest.TestCase):
def test_long_float_rounds_to_two_decimals(self) -> None:
self.assertEqual(_format_model_value(40.123456789), "40.12")
def test_long_decimal_rounds_to_two_decimals(self) -> None:
self.assertEqual(_format_model_value(Decimal("40.123456789")), "40.12")
def test_trailing_zeros_stripped(self) -> None:
self.assertEqual(_format_model_value(40.30), "40.3")
self.assertEqual(_format_model_value(40.0), "40")
self.assertEqual(_format_model_value(Decimal("40.10")), "40.1")
def test_none_and_empty_return_none(self) -> None:
self.assertIsNone(_format_model_value(None))
self.assertIsNone(_format_model_value(" "))
def test_non_numeric_passthrough(self) -> None:
self.assertEqual(_format_model_value("n/a"), "n/a")
class IncubLabelTests(unittest.TestCase):
def test_picks_max_raw_value_then_rounds_suffix(self) -> None:
self.assertEqual(
_incub_label([
("INCUBAZPRIMARIA", 40.12),
("INCUBAZPRIMARIA", 40.129),
("INCUBAZPRIMARIA", 39.99),
]),
"INCUBAZPRIMARIA_40.13",
)
def test_keeps_index_of_winning_row(self) -> None:
self.assertEqual(
_incub_label([("INCUBAZPRIMARIA2", 80.7)]),
"INCUBAZPRIMARIA2_80.7",
)
def test_higher_percentage_beats_higher_index(self) -> None:
self.assertEqual(
_incub_label([
("INCUBAZPRIMARIA2", 40),
("INCUBAZPRIMARIA1", 90),
]),
"INCUBAZPRIMARIA1_90",
)
def test_tie_keeps_higher_index(self) -> None:
self.assertEqual(
_incub_label([
("INCUBAZPRIMARIA1", 80.7),
("INCUBAZPRIMARIA2", 80.7),
]),
"INCUBAZPRIMARIA2_80.7",
)
def test_missing_index_loses_tie(self) -> None:
self.assertEqual(
_incub_label([
("INCUBAZPRIMARIA", 40),
("INCUBAZPRIMARIA2", 40),
]),
"INCUBAZPRIMARIA2_40",
)
def test_space_or_underscore_is_not_an_index(self) -> None:
self.assertEqual(
_incub_label([("INCUBAZPRIMARIA 2", 40), ("INCUBAZPRIMARIA_2", 30)]),
"INCUBAZPRIMARIA_40",
)
def test_empty_or_non_numeric_falls_back(self) -> None:
self.assertEqual(_incub_label([]), "INCUBAZPRIMARIA")
self.assertEqual(
_incub_label([("INCUBAZPRIMARIA", None), ("INCUBAZPRIMARIA2", "n/a")]),
"INCUBAZPRIMARIA",
)
def test_day_keeps_each_index_and_fase5(self) -> None:
records = _DayRecords(
descriptions={"INCUBAZPRIMARIA1", "INCUBAZPRIMARIA2", "FASE5", "FASE2"},
incub_rows=[
("INCUBAZPRIMARIA1", 90),
("INCUBAZPRIMARIA2", 40),
],
)
self.assertEqual(
_resolve_day_fase(records),
["INCUBAZPRIMARIA1_90", "INCUBAZPRIMARIA2_40", "FASE5"],
)
def test_incubation_only_keeps_highest_value_per_index(self) -> None:
records = _DayRecords(
descriptions={"INCUBAZPRIMARIA2"},
incub_rows=[
("INCUBAZPRIMARIA2", 40),
("INCUBAZPRIMARIA2", 80.7),
],
)
self.assertEqual(_resolve_day_fase(records), ["INCUBAZPRIMARIA2_80.7"])
def test_fase2_without_incubation(self) -> None:
records = _DayRecords(descriptions={"FASE2"})
self.assertEqual(_resolve_day_fase(records), ["FASE2"])
def test_empty_day_is_none(self) -> None:
self.assertIsNone(_resolve_day_fase(_DayRecords()))
def _model(anmod_id: int, name: str = "PERONOSPORA", advice: int = 1) -> SimpleNamespace:
return SimpleNamespace(anmod_id=anmod_id, anmod_model=name, anmod_AI_advice=advice)
def _tmp_row(day: date, description: str, value: object, station: int = 7) -> SimpleNamespace:
return SimpleNamespace(
model_day=day,
model_description=description,
model_value=value,
model_station=station,
)
class AdviceModelLookupTests(unittest.TestCase):
def test_keeps_every_advice_enabled_id_for_the_disease(self) -> None:
rows = [
_model(20),
_model(11, name="Peronospora"),
_model(12, advice=0),
_model(13, name="OIDIO"),
]
with patch("pipeline.stages.disease.fetch_all", return_value=rows) as fetch:
ids = find_advice_anmod_ids(object(), 4012, "PERONOSPORA")
self.assertEqual(ids, [11, 20])
self.assertIn("anmod_AI_advice", fetch.call_args.args[1])
def test_disabled_only_match_is_not_configured(self) -> None:
with patch("pipeline.stages.disease.fetch_all", return_value=[_model(12, advice=0)]):
with self.assertRaises(NoModelConfiguredError):
find_advice_anmod_ids(object(), 4012, "PERONOSPORA")
def test_forecast_reads_model_description_for_that_anmod_id(self) -> None:
as_of = date(2026, 6, 15)
with patch(
"pipeline.stages.disease.fetch_all",
return_value=[_tmp_row(as_of, "INCUBAZPRIMARIA2", 80.7)],
) as fetch:
forecast = resolve_disease_forecast_by_anmod(
object(),
anmod_id=11,
disease_name="PERONOSPORA",
disease_english="downy mildew",
as_of=as_of,
fallback_station=None,
)
sql, params = fetch.call_args.args[1], fetch.call_args.args[2]
self.assertIn("WHERE model_model = ?", sql)
self.assertIn("model_description", sql)
self.assertEqual(params[0], 11)
self.assertEqual(forecast.anmod_id, 11)
self.assertEqual(forecast.forecasts_by_day[as_of], ["INCUBAZPRIMARIA2_80.7"])
def test_single_enabled_model_is_used_for_the_forecast(self) -> None:
as_of = date(2026, 6, 15)
with patch(
"pipeline.stages.disease.fetch_all",
side_effect=[[_model(11)], [_tmp_row(as_of, "FASE5", None, station=4)]],
) as fetch:
forecast = resolve_disease_forecast(
object(),
4012,
"PERONOSPORA",
"downy mildew",
as_of,
None,
)
self.assertEqual(forecast.anmod_id, 11)
self.assertEqual(forecast.forecasts_by_day[as_of], ["FASE5"])
self.assertEqual(fetch.call_args_list[1].args[2][0], 11)
def test_several_enabled_models_are_not_collapsed_to_the_first(self) -> None:
rows = [_model(11), _model(20)]
with patch("pipeline.stages.disease.fetch_all", return_value=rows):
with self.assertRaises(PipelineError):
resolve_disease_forecast(
object(),
4012,
"PERONOSPORA",
"downy mildew",
date(2026, 6, 15),
None,
)
if __name__ == "__main__":
unittest.main()