Refactor assemble.py and disease.py for clarity and functionality

- Moved the assignment of `phenology_phase` in assemble.py to ensure it is included only for valid days.
- Updated the docstring in disease.py to clarify the rounding behavior of decimal values, enhancing the understanding of the formatting logic.
- Removed unnecessary checks in the decimal formatting function to streamline the code.
This commit is contained in:
Arsham Mirehvandi 2026-09-16 22:40:14 +02:00
parent 86f660cb81
commit d45e4de40b
4 changed files with 98 additions and 16 deletions

View File

@ -48,11 +48,11 @@ def _day_entry(
entry: dict[str, Any] = {"date": format_date(day)} entry: dict[str, Any] = {"date": format_date(day)}
for key in METRIC_KEYS: for key in METRIC_KEYS:
entry[key] = _metric_value(key, metrics.get(key)) entry[key] = _metric_value(key, metrics.get(key))
entry["phenology_phase"] = phenology.get(day)
entry["disease_forecast"] = forecasts.get(day) entry["disease_forecast"] = forecasts.get(day)
# Observation and applied_treatment are only ever known for as_of and # Phenology, observation, and applied_treatment are only ever known for
# earlier; future days have neither, so both fields are omitted for them. # as_of and earlier; future days have none, so those fields are omitted.
if day <= as_of: if day <= as_of:
entry["phenology_phase"] = phenology.get(day)
entry["observation"] = bool((observation or {}).get(day, False)) entry["observation"] = bool((observation or {}).get(day, False))
entry["applied_treatment"] = list(applied_treatments.get(day, [])) entry["applied_treatment"] = list(applied_treatments.get(day, []))
return entry return entry

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date from datetime import date
from decimal import Decimal, InvalidOperation from decimal import Decimal
from typing import Any from typing import Any
import pyodbc import pyodbc
@ -55,21 +55,15 @@ def _is_incubazprimaria(label: str | None) -> bool:
def _format_model_value(value: Any) -> str | None: def _format_model_value(value: Any) -> str | None:
"""Compact decimal string: 40.3, not 40.3000000001 or 4.03e+01.""" """Round to 2 decimals, then compact: 40.12, 40.3, 40 — not 40.123456789."""
if value is None: if value is None:
return None return None
if isinstance(value, Decimal):
text = format(value, "f")
elif isinstance(value, float):
text = format(Decimal(str(value)), "f")
else:
text = str(value).strip()
if not text:
return None
try: try:
text = format(Decimal(text), "f") rounded = round(float(value), 2)
except (InvalidOperation, ValueError): except (TypeError, ValueError):
return text text = str(value).strip()
return text or None
text = format(Decimal(str(rounded)), "f")
if "." in text: if "." in text:
text = text.rstrip("0").rstrip(".") text = text.rstrip("0").rstrip(".")
return text or None return text or None

44
tests/test_assemble.py Normal file
View File

@ -0,0 +1,44 @@
"""Unit tests for advice-generation JSON assembly."""
from __future__ import annotations
import unittest
from datetime import date, timedelta
from pipeline.stages.assemble import assemble_json_for_advice_generation
from pipeline.window import build_window, format_date
_AS_OF = date(2026, 4, 16)
_PHASE = "BBCH 15"
class AdviceGenerationPhenologyTests(unittest.TestCase):
def test_phenology_phase_only_on_past_and_today(self) -> None:
window = build_window(_AS_OF)
phenology = {day: _PHASE if day <= _AS_OF else None for day in window}
payload = assemble_json_for_advice_generation(
_AS_OF,
weather={},
phenology=phenology,
forecasts={},
)
self.assertEqual(payload["date_of_today"], format_date(_AS_OF))
self.assertEqual(len(payload["meteorological_data"]), 6)
self.assertEqual(len(payload["weather_forecasts"]), 5)
for entry in payload["meteorological_data"]:
self.assertIn("phenology_phase", entry)
self.assertEqual(entry["phenology_phase"], _PHASE)
for entry in payload["weather_forecasts"]:
self.assertNotIn("phenology_phase", entry)
last_meteo = payload["meteorological_data"][-1]
self.assertEqual(last_meteo["date"], format_date(_AS_OF))
first_forecast = payload["weather_forecasts"][0]
self.assertEqual(first_forecast["date"], format_date(_AS_OF + timedelta(days=1)))
if __name__ == "__main__":
unittest.main()

44
tests/test_disease.py Normal file
View File

@ -0,0 +1,44 @@
"""Unit tests for INCUBAZPRIMARIA model_value rounding and labels."""
from __future__ import annotations
import unittest
from decimal import Decimal
from pipeline.stages.disease import _format_model_value, _incub_label
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([40.12, 40.129, 39.99]),
"INCUBAZPRIMARIA_40.13",
)
def test_empty_or_non_numeric_falls_back(self) -> None:
self.assertEqual(_incub_label([]), "INCUBAZPRIMARIA")
self.assertEqual(_incub_label([None, "n/a"]), "INCUBAZPRIMARIA")
if __name__ == "__main__":
unittest.main()