AI_Agro_Support/pipeline/worklist.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

144 lines
4.4 KiB
Python

"""Database-driven job discovery: find every advice-enabled agro-model row to run today.
The batch does not start from a field ID or from a crop list in config.yaml.
It reads the diseases that can generate advice
(`AI_agrosupport_an_pathogen_models.pat_mod_AI_model = 1`, named by
`pat_mod_desc`) and the fields that have advice enabled for one of those
diseases (`AI_agrosupport_agro_models.anmod_AI_advice = 1`). The cultivated
crop comes from the field layer. Each matching agro-model row becomes one
`Job`, including several mathematical models (`anmod_id`) for the same field
and disease.
"""
from __future__ import annotations
import logging
from collections import Counter
from dataclasses import dataclass
import pyodbc
from pipeline.config import WorklistSettings
from pipeline.db import fetch_all
from pipeline.errors import PipelineError
from pipeline.vocab import Vocabulary
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class Job:
"""One field, disease, and mathematical model (`anmod_id`) to run."""
field_id: int
crop_english: str
crop_italian: str
model_name: str # e.g. "PERONOSPORA"; matched against anmod_model
disease_english: str # canonical English disease name
organic: bool
station: int | None
anmod_id: int
_JOBS_SQL = """
SELECT DISTINCT
l.cmplay_id,
l.cmplay_colture,
l.cmplay_imp,
l.cmplay_station,
c.colture_name,
m.anmod_id,
m.anmod_model
FROM AI_agrosupport_agro_models m
INNER JOIN (
SELECT DISTINCT LTRIM(RTRIM(pat_mod_desc)) AS model_name
FROM AI_agrosupport_an_pathogen_models
WHERE pat_mod_AI_model = 1
AND pat_mod_desc IS NOT NULL
AND LTRIM(RTRIM(pat_mod_desc)) <> ''
) p
ON UPPER(LTRIM(RTRIM(m.anmod_model))) = UPPER(p.model_name)
INNER JOIN AI_agrosupport_cmp_layers l
ON l.cmplay_id = m.anmod_cmplay
LEFT JOIN AI_agrosupport_an_colture c
ON c.colture_id = l.cmplay_colture
WHERE m.anmod_AI_advice = 1
"""
def build_jobs(
conn: pyodbc.Connection,
worklist: WorklistSettings,
crop_vocab: Vocabulary,
disease_vocab: Vocabulary,
) -> list[Job]:
"""Discover every (field, disease) job enabled in the database."""
allowlist = set(worklist.field_allowlist)
rows = fetch_all(conn, _JOBS_SQL)
jobs: list[Job] = []
unmapped_diseases: list[str] = []
unmapped_colture: list[str] = []
for row in rows:
if allowlist and int(row.cmplay_id) not in allowlist:
continue
model_name = (row.anmod_model or "").strip()
try:
disease_english = disease_vocab.to_english(model_name, kind="disease")
except PipelineError:
unmapped_diseases.append(model_name or "(blank)")
continue
colture_name = (row.colture_name or "").strip()
try:
crop_english = crop_vocab.to_english(colture_name, kind="crop")
except PipelineError:
unmapped_colture.append(colture_name or "(blank)")
continue
jobs.append(
Job(
field_id=int(row.cmplay_id),
crop_english=crop_english,
crop_italian=colture_name,
model_name=model_name,
disease_english=disease_english,
organic=row.cmplay_imp == 3,
station=(
int(row.cmplay_station) if row.cmplay_station is not None else None
),
anmod_id=int(row.anmod_id),
)
)
jobs.sort(key=lambda job: (job.field_id, job.model_name, job.anmod_id))
if unmapped_diseases:
logger.warning(
"%d disease model name(s) have no disease vocabulary mapping and will be "
"ignored by the worklist: %s",
len(set(unmapped_diseases)),
sorted(set(unmapped_diseases)),
)
if unmapped_colture:
logger.warning(
"%d colture name(s) have no crop vocabulary mapping and will be "
"ignored by the worklist: %s",
len(set(unmapped_colture)),
sorted(set(unmapped_colture)),
)
counts = Counter((job.crop_english, job.disease_english, job.model_name) for job in jobs)
for crop, disease, model_name in sorted(counts):
logger.info(
"Worklist: crop=%s disease=%s (model=%s) -> %d job(s)",
crop,
disease,
model_name,
counts[(crop, disease, model_name)],
)
return jobs