AI_Agro_Support/tests/test_batch.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

271 lines
9.7 KiB
Python

"""Unit tests for the 09:00 readiness target: late jobs still run."""
from __future__ import annotations
import tempfile
import unittest
from datetime import date, datetime, timedelta
from datetime import time as dtime
from pathlib import Path
from unittest.mock import MagicMock, patch
from pipeline.batch import _deadline_notice, run_batch
from pipeline.config import (
ConcurrencySettings,
LlmSettings,
ObservabilitySettings,
RetrySettings,
ScheduleSettings,
Settings,
SqlSettings,
WeaviateSettings,
WorklistSettings,
)
from pipeline.job import JobResult, output_stem
from pipeline.output import write_run_report
from pipeline.worklist import Job
def _at(hour: int, minute: int = 0, second: int = 0) -> datetime:
return datetime.combine(date.today(), dtime(hour, minute, second))
def _job(field_id: int = 4012, anmod_id: int = 10) -> Job:
return Job(
field_id=field_id,
crop_english="grapevine",
crop_italian="Vite",
model_name="PERONOSPORA",
disease_english="downy mildew",
organic=False,
station=7,
anmod_id=anmod_id,
)
def _settings() -> Settings:
llm = LlmSettings(
provider="gemini",
openai_model="m",
anthropic_model="m",
gemini_model="m",
max_tokens=8,
temperature=0.0,
thinking_level=None,
)
return Settings(
root=Path("."),
field_id=None,
disease_name=None,
worklist=WorklistSettings(field_allowlist=()),
concurrency=ConcurrencySettings(
workers=1,
sql_limit=1,
gemini_limit=1,
gemini_requests_per_minute=60,
),
retry=RetrySettings(attempts=1, initial_backoff_seconds=0.0, max_backoff_seconds=0.0),
schedule=ScheduleSettings(deadline="09:00"),
observability=ObservabilitySettings(
enabled=False,
project_name="p",
endpoint="http://localhost",
hide_prompts=True,
max_attribute_chars=100,
),
crops_vocab=Path("crops.yaml"),
diseases_vocab=Path("diseases.yaml"),
llm_query_synthesis=llm,
llm_advice_generation=llm,
sql=SqlSettings(
driver="d",
server="localhost",
database="db",
app_database="db",
username="u",
password="p",
),
weaviate=WeaviateSettings(host="localhost", http_port=8080, grpc_port=50051),
gemini_api_key="",
openai_api_key="",
anthropic_api_key="",
prompts_dir=Path("prompts"),
output_dir=Path("output"),
)
class DeadlineNoticeTests(unittest.TestCase):
def test_before_80_percent_is_quiet(self) -> None:
start = datetime(2026, 10, 14, 7, 0)
deadline = datetime(2026, 10, 14, 9, 0)
now = start + timedelta(seconds=0.8 * 7200 - 1)
self.assertIsNone(_deadline_notice(now, deadline, start))
def test_at_80_percent_and_still_before_deadline(self) -> None:
start = datetime(2026, 10, 14, 7, 0)
deadline = datetime(2026, 10, 14, 9, 0)
now = start + timedelta(seconds=0.8 * 7200)
self.assertLess(now, deadline)
self.assertEqual(_deadline_notice(now, deadline, start), "budget_80")
def test_exactly_at_deadline_is_late(self) -> None:
start = datetime(2026, 10, 14, 7, 0)
deadline = datetime(2026, 10, 14, 9, 0)
self.assertEqual(_deadline_notice(deadline, deadline, start), "late")
def test_after_deadline_is_late(self) -> None:
start = datetime(2026, 10, 14, 7, 0)
deadline = datetime(2026, 10, 14, 9, 0)
now = datetime(2026, 10, 14, 9, 5)
self.assertEqual(_deadline_notice(now, deadline, start), "late")
def test_batch_already_started_past_deadline_is_late(self) -> None:
deadline = datetime(2026, 10, 14, 9, 0)
start = datetime(2026, 10, 14, 10, 0)
now = datetime(2026, 10, 14, 10, 1)
self.assertEqual(_deadline_notice(now, deadline, start), "late")
class RunBatchDeadlineTests(unittest.TestCase):
def _run(
self,
jobs: list[Job],
*,
now,
skip_existing: bool = False,
existing: set[int] | None = None,
log_level: str = "WARNING",
) -> dict:
captured: dict = {}
prompts = MagicMock()
prompts.validate.return_value = []
def _write_report(output_dir, as_of, results, *, jobs_started_after_deadline=0):
captured["results"] = list(results)
captured["late"] = jobs_started_after_deadline
return {"totals": {}}
def _run_job(resources, job, as_of, dry_run=False):
return JobResult(job=job, status="ok", payload={"output_stem": str(job.field_id)})
now_fn = now if callable(now) else (lambda tz=None: now)
with (
patch("pipeline.batch.PromptRegistry", return_value=prompts),
patch("pipeline.batch.Resources", return_value=MagicMock()),
patch("pipeline.batch.build_jobs", return_value=jobs),
patch("pipeline.batch.run_job", side_effect=_run_job) as run_job,
patch("pipeline.batch.write_job_output"),
patch("pipeline.batch.write_run_report", side_effect=_write_report),
patch("pipeline.batch._existing_advice_keys", return_value=existing or set()),
patch("pipeline.batch.datetime") as frozen,
self.assertLogs("pipeline.batch", level=log_level) as logs,
):
frozen.now.side_effect = now_fn
frozen.combine = datetime.combine
captured["code"] = run_batch(
_settings(),
MagicMock(),
MagicMock(),
date(2026, 10, 14),
skip_existing_advice=skip_existing,
)
captured["run_job"] = run_job
captured["logs"] = [record.getMessage() for record in logs.records]
return captured
def test_job_after_deadline_still_runs(self) -> None:
captured = self._run([_job()], now=_at(10, 0))
self.assertEqual(captured["run_job"].call_count, 1)
self.assertEqual(captured["late"], 1)
self.assertEqual(captured["code"], 0)
self.assertEqual([result.status for result in captured["results"]], ["ok"])
self.assertTrue(all(result.status != "skipped_deadline" for result in captured["results"]))
self.assertEqual(sum("has passed" in message for message in captured["logs"]), 1)
def test_two_late_jobs_warn_once(self) -> None:
jobs = [_job(4012), _job(4013)]
captured = self._run(jobs, now=_at(10, 0))
self.assertEqual(captured["run_job"].call_count, 2)
self.assertEqual(captured["late"], 2)
self.assertEqual(sum("has passed" in message for message in captured["logs"]), 1)
def test_existing_advice_after_deadline_is_not_a_late_start(self) -> None:
job = _job()
captured = self._run(
[job],
now=_at(10, 0),
skip_existing=True,
existing={job.anmod_id},
)
self.assertEqual(captured["run_job"].call_count, 0)
self.assertEqual(captured["late"], 0)
self.assertEqual([result.status for result in captured["results"]], ["skipped_existing"])
def test_budget_warning_is_logged_once(self) -> None:
start = _at(7, 0)
at_80 = start + timedelta(seconds=0.8 * 2 * 3600)
later = at_80 + timedelta(minutes=5)
times = iter([start, at_80, later])
captured = self._run([_job(4012), _job(4013)], now=lambda tz=None: next(times))
self.assertEqual(captured["run_job"].call_count, 2)
self.assertEqual(captured["late"], 0)
self.assertEqual(sum("80%" in message for message in captured["logs"]), 1)
self.assertFalse(any("has passed" in message for message in captured["logs"]))
def test_skip_existing_is_per_pathogen_model(self) -> None:
stored = _job(anmod_id=20)
other = _job(anmod_id=11)
captured = self._run(
[stored, other],
now=_at(8, 0),
skip_existing=True,
existing={stored.anmod_id},
log_level="INFO",
)
self.assertEqual(captured["run_job"].call_count, 1)
self.assertEqual(captured["run_job"].call_args.args[1].anmod_id, 11)
self.assertEqual(
sorted(result.status for result in captured["results"]),
["ok", "skipped_existing"],
)
class OutputStemTests(unittest.TestCase):
def test_each_model_has_its_own_file_stem(self) -> None:
stems = {
output_stem(_job(anmod_id=11), "downy mildew"),
output_stem(_job(anmod_id=20), "downy mildew"),
}
self.assertEqual(
stems,
{
"field4012__grapevine__downy_mildew__model11",
"field4012__grapevine__downy_mildew__model20",
},
)
class RunReportTests(unittest.TestCase):
def test_report_records_late_starts_without_skipped_deadline(self) -> None:
result = JobResult(job=_job(), status="ok")
with tempfile.TemporaryDirectory() as tmp:
report = write_run_report(
Path(tmp),
date(2026, 10, 14),
[result],
jobs_started_after_deadline=2,
)
empty = write_run_report(Path(tmp), date(2026, 10, 14), [])
self.assertEqual(report["jobs_started_after_deadline"], 2)
self.assertEqual(report["jobs"][0]["anmod_id"], 10)
self.assertNotIn("skipped_deadline", report["totals"])
self.assertIn("skipped_existing", report["totals"])
self.assertEqual(empty["jobs_started_after_deadline"], 0)
self.assertNotIn("skipped_deadline", empty["totals"])