- 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.
220 lines
7.5 KiB
Python
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()
|