Enhance disease forecasting and product prefiltering logic
- Updated README.md to clarify the handling of historical/test dates and the conditions for generating advice. - Modified job.py to incorporate observation data into risk assessment and product prefiltering. - Introduced a new function in disease.py to select the appropriate prefilter rule based on observation and FASE data. - Enhanced product filtering logic in products.py to accommodate new prefilter rules. - Updated assemble.py to include observation data in JSON assembly for advice generation. - Improved documentation in the grapevine downy mildew system prompt to explain the significance of INCUBAZPRIMARIA and observation data.
This commit is contained in:
parent
21927133a4
commit
98afec550d
47
README.md
47
README.md
@ -116,7 +116,7 @@ prompt for a new pair, create `prompts/advice/<slug>/system.md` and `user.md`;
|
||||
# Daily entry point: every crop/disease pair in config.yaml, across all matching fields
|
||||
python -m pipeline batch
|
||||
|
||||
# Historical / test dates (useful when the live window has no FASE2/FASE5)
|
||||
# Historical / test dates (useful when the live window has no FASE2/FASE5/INCUBAZPRIMARIA/observation)
|
||||
python -m pipeline batch --as-of 2026-06-29
|
||||
|
||||
# Narrow to one crop, disease, and/or field while testing
|
||||
@ -194,11 +194,12 @@ Per-job payload fields:
|
||||
| `status` | `ok`, `no_risk`, or `dry_run` (see `_run_report.json` for `failed` / `skipped_*`, which have no per-job file) |
|
||||
| `message` | Human-readable summary of the run outcome |
|
||||
|
||||
If neither `FASE2` nor `FASE5` appears in a field's 11-day window, the product
|
||||
search is skipped and `allowed_products` is empty, but advice is still generated
|
||||
and stored; that job is labelled `no_risk`. The run report also counts `ok` jobs
|
||||
whose `allowed_products` ended up empty anyway (for example an organic field
|
||||
with no organic-certified product in the catalog yet) — watch that number as the
|
||||
If a field's 11-day window has no `FASE2`, `FASE5`, or `INCUBAZPRIMARIA` day and
|
||||
no true `observation` day (`as_of-5 .. as_of`), the product search is skipped
|
||||
and `allowed_products` is empty, but advice is still generated and stored; that
|
||||
job is labelled `no_risk`. The run report also counts `ok` jobs whose
|
||||
`allowed_products` ended up empty anyway (for example an organic field with no
|
||||
organic-certified product in the catalog yet) — watch that number as the
|
||||
`products` table fills in, since a sudden jump usually means the prefilter or
|
||||
the Weaviate allowlist filter is misbehaving rather than that the catalog is
|
||||
genuinely empty for that combination.
|
||||
@ -344,14 +345,26 @@ is not worth a custom cost pipeline at this scale.
|
||||
Run once per `(field, disease)` job by `pipeline/job.py::run_job`:
|
||||
|
||||
1. **Worklist** — crop → fields (`AI_agrosupport_an_colture` + `AI_agrosupport_cmp_layers`) → disease model (`AI_agrosupport_agro_models`); organic flag is `cmplay_imp == 3` (`pipeline/worklist.py`, run once per batch, not per job)
|
||||
2. **Disease model** — per-day `FASE5` > `FASE2` resolution over as_of±5, from the worklist's known `anmod_id`
|
||||
3. **Weather** — `TDatiMeteo_D` for past days, `TDatiMeteo_D_FRC` for today/future
|
||||
4. **Phenology** — carry-forward of latest observation ≤ day; future days null; if today's phase is missing, use yesterday's
|
||||
5. **Applied treatments** — products sprayed over `as_of-5 .. as_of`, from `AI_agrosupport_agro_ril_operations*` with `rilop_operation = 10`
|
||||
6. **LLM** — synthesise 1–2 anonymised search queries from the subset
|
||||
7. **Product prefilter** — organic / crop / disease / FASE rules against the batch-wide, in-memory `products` index
|
||||
8. **Vector search** — Weaviate `near_text` on `ProductProfile`, filtered server-side to the allowlisted `product_id`s
|
||||
9. **Product JSON** — label details per product from `products`, `product_uses`, `label_chunks`, cached per `(crop, disease)`
|
||||
10. **Context enrichment** — `allowed_products`, `applied_treatment` expansion, `last_advice` from the `advice` table
|
||||
11. **Advice generation** — second LLM profile returns the structured advisory
|
||||
12. **Persistence** — the advisory plus its collapsed input are written to `advice` inside one transaction
|
||||
2. **Disease model** — per-day incubation > `FASE5` > `FASE2` resolution over as_of±5, from the worklist's known `anmod_id`; a `model_description` containing `INCUBAZPRIMARIA` is stored as `INCUBAZPRIMARIA_<model_value>` (e.g. `INCUBAZPRIMARIA_40.3`) from `AI_agrosupport_agro_model_tmp2.model_value`
|
||||
3. **Observation** — `AI_agrosupport_agro_ril_pathogen` (`rilpato_layer`/`rilpato_date`/`rilpato_diffusion`); `observation` is true for `as_of` or any of the past 5 days if a row exists there with `rilpato_diffusion` other than `32` (including `NULL`)
|
||||
4. **Weather** — `TDatiMeteo_D` for past days, `TDatiMeteo_D_FRC` for today/future
|
||||
5. **Phenology** — carry-forward of latest observation ≤ day; future days null; if today's phase is missing, use yesterday's
|
||||
6. **Applied treatments** — products sprayed over `as_of-5 .. as_of`, from `AI_agrosupport_agro_ril_operations*` with `rilop_operation = 10`
|
||||
7. **LLM** — synthesise 1–2 anonymised search queries from the subset
|
||||
8. **Product prefilter** — organic / crop / disease gates, then one prefilter rule against the batch-wide, in-memory `products` index (`pipeline/stages/disease.py::select_prefilter_rule`, first match wins):
|
||||
|
||||
| Rule | Predicates |
|
||||
|---|---|
|
||||
| `observation` true on `as_of` or the past 5 days | `systemicity` in `{systemic, mixed}` and `eradicant_action = 1` |
|
||||
| `FASE2` anywhere and no `FASE5` anywhere | `systemicity == contact` and `preventive_action = 1` and `curative_action = 0` and `eradicant_action = 0` |
|
||||
| `FASE5` both past/today and future | `systemicity == mixed` and `preventive_action = 1` and `curative_action = 1` and `eradicant_action = 0` |
|
||||
| `FASE5` in the future only | same predicates as the `FASE2`-only rule |
|
||||
| `FASE5` today or in the past only | `systemicity` in `{contact, mixed}` and `preventive_action = 1` and `curative_action = 0` and `eradicant_action = 0` |
|
||||
| `INCUBAZPRIMARIA` / `INCUBAZPRIMARIA_<value>` today or in the past 5 days | `systemicity` in `{systemic, mixed}` and `curative_action = 1` and `eradicant_action = 0` |
|
||||
| none of the above | no FASE-specific cut; organic/crop/disease gates only |
|
||||
|
||||
9. **Vector search** — Weaviate `near_text` on `ProductProfile`, filtered server-side to the allowlisted `product_id`s
|
||||
10. **Product JSON** — label details per product from `products`, `product_uses`, `label_chunks`, cached per `(crop, disease)`
|
||||
11. **Context enrichment** — `allowed_products`, `applied_treatment` expansion, `last_advice` from the `advice` table
|
||||
12. **Advice generation** — second LLM profile returns the structured advisory
|
||||
13. **Persistence** — the advisory plus its collapsed input are written to `advice` inside one transaction
|
||||
|
||||
@ -38,8 +38,9 @@ from pipeline.stages.assemble import (
|
||||
assemble_json_for_advice_generation,
|
||||
build_json_for_query_synthesis,
|
||||
)
|
||||
from pipeline.stages.disease import nearest_fase, resolve_disease_forecast_by_anmod
|
||||
from pipeline.stages.disease import resolve_disease_forecast_by_anmod, select_prefilter_rule
|
||||
from pipeline.stages.llm import synthesize_queries
|
||||
from pipeline.stages.observation import load_observation
|
||||
from pipeline.stages.phenology import load_phenology
|
||||
from pipeline.stages.products import prefilter_products
|
||||
from pipeline.stages.treatments import load_applied_treatments
|
||||
@ -159,6 +160,13 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) -
|
||||
weather = load_weather(conn, disease.station, as_of)
|
||||
phenology = load_phenology(conn, job.field_id, as_of)
|
||||
applied_treatments = load_applied_treatments(conn, job.field_id, as_of)
|
||||
observation_by_day = load_observation(conn, job.field_id, as_of)
|
||||
|
||||
# has_risk gates query synthesis and the product prefilter: it is true if
|
||||
# any day is FASE2 / FASE5 / INCUBAZPRIMARIA (disease.has_risk, already
|
||||
# covers all three via forecasts_by_day) or the observation factor is
|
||||
# true for as_of or any of the past 5 days.
|
||||
has_risk = disease.has_risk or any(observation_by_day.values())
|
||||
|
||||
logger.info(
|
||||
"Field %s (%s/%s): station=%s has_risk=%s first_future=%s",
|
||||
@ -166,7 +174,7 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) -
|
||||
job.crop_english,
|
||||
job.disease_english,
|
||||
disease.station,
|
||||
disease.has_risk,
|
||||
has_risk,
|
||||
disease.first_future_disease_date,
|
||||
)
|
||||
|
||||
@ -176,6 +184,7 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) -
|
||||
phenology,
|
||||
disease.forecasts_by_day,
|
||||
applied_treatments,
|
||||
observation_by_day,
|
||||
)
|
||||
json_for_query_synthesis = build_json_for_query_synthesis(
|
||||
as_of,
|
||||
@ -197,12 +206,13 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) -
|
||||
"organic": job.organic,
|
||||
"station": disease.station,
|
||||
"anmod_id": disease.anmod_id,
|
||||
"has_risk": disease.has_risk,
|
||||
"has_risk": has_risk,
|
||||
"first_future_disease_date": (
|
||||
disease.first_future_disease_date.isoformat()
|
||||
if disease.first_future_disease_date
|
||||
else None
|
||||
),
|
||||
"prefilter_rule": None,
|
||||
"llm_provider": settings.llm_query_synthesis.provider,
|
||||
},
|
||||
"json_for_advice_generation": json_for_advice_generation,
|
||||
@ -227,7 +237,7 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) -
|
||||
return payload
|
||||
|
||||
recommended: list[RecommendedProduct] = []
|
||||
if disease.has_risk:
|
||||
if has_risk:
|
||||
query_system_prompt = resources.prompts.query_prompt(job.crop_english, disease.disease_english)
|
||||
queries = synthesize_queries(
|
||||
settings,
|
||||
@ -237,13 +247,14 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) -
|
||||
)
|
||||
payload["generated_queries"] = queries
|
||||
|
||||
fase = nearest_fase(disease.forecasts_by_day, as_of)
|
||||
prefilter_rule = select_prefilter_rule(disease.forecasts_by_day, observation_by_day, as_of)
|
||||
payload["run"]["prefilter_rule"] = prefilter_rule
|
||||
candidates = prefilter_products(
|
||||
resources.product_index,
|
||||
crop_english=job.crop_english,
|
||||
disease_english=disease.disease_english,
|
||||
organic=job.organic,
|
||||
fase=fase,
|
||||
rule=prefilter_rule,
|
||||
)
|
||||
payload["candidate_count"] = len(candidates)
|
||||
|
||||
@ -266,8 +277,10 @@ def _run_job_inner(resources: Resources, job: Job, as_of: date, dry_run: bool) -
|
||||
else:
|
||||
payload["status"] = "no_risk"
|
||||
logger.warning(
|
||||
"Field %s: neither FASE2 nor FASE5 in the 11-day window; product "
|
||||
"search skipped, advice generated without allowed products.",
|
||||
"Field %s: no FASE2, FASE5, or INCUBAZPRIMARIA in the 11-day "
|
||||
"window, and no true observation in the past 5 days or today; "
|
||||
"product search skipped, advice generated without allowed "
|
||||
"products.",
|
||||
job.field_id,
|
||||
)
|
||||
|
||||
|
||||
@ -42,6 +42,7 @@ def _day_entry(
|
||||
phenology: dict[date, str | None],
|
||||
forecasts: dict[date, str | None],
|
||||
applied_treatments: dict[date, list[str]],
|
||||
observation: dict[date, bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
metrics = weather.get(day, {})
|
||||
entry: dict[str, Any] = {"date": format_date(day)}
|
||||
@ -49,9 +50,10 @@ def _day_entry(
|
||||
entry[key] = _metric_value(key, metrics.get(key))
|
||||
entry["phenology_phase"] = phenology.get(day)
|
||||
entry["disease_forecast"] = forecasts.get(day)
|
||||
# Treatments can only have been applied on or before as_of; future days
|
||||
# have no applied treatment, so the field is omitted for them.
|
||||
# Observation and applied_treatment are only ever known for as_of and
|
||||
# earlier; future days have neither, so both fields are omitted for them.
|
||||
if day <= as_of:
|
||||
entry["observation"] = bool((observation or {}).get(day, False))
|
||||
entry["applied_treatment"] = list(applied_treatments.get(day, []))
|
||||
return entry
|
||||
|
||||
@ -62,17 +64,18 @@ def assemble_json_for_advice_generation(
|
||||
phenology: dict[date, str | None],
|
||||
forecasts: dict[date, str | None],
|
||||
applied_treatments: dict[date, list[str]] | None = None,
|
||||
observation: dict[date, bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build meteorological_data (as_of-5..as_of) and weather_forecasts (as_of+1..as_of+5)."""
|
||||
window = build_window(as_of)
|
||||
applied = applied_treatments or {}
|
||||
meteorological = [
|
||||
_day_entry(day, as_of, weather, phenology, forecasts, applied)
|
||||
_day_entry(day, as_of, weather, phenology, forecasts, applied, observation)
|
||||
for day in window
|
||||
if day <= as_of
|
||||
]
|
||||
forecasts_section = [
|
||||
_day_entry(day, as_of, weather, phenology, forecasts, applied)
|
||||
_day_entry(day, as_of, weather, phenology, forecasts, applied, observation)
|
||||
for day in window
|
||||
if day > as_of
|
||||
]
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
import pyodbc
|
||||
|
||||
@ -33,6 +35,7 @@ _TMP2_SQL = """
|
||||
SELECT
|
||||
CONVERT(date, model_date) AS model_day,
|
||||
model_description,
|
||||
model_value,
|
||||
model_station
|
||||
FROM AI_agrosupport_agro_model_tmp2
|
||||
WHERE model_model = ?
|
||||
@ -41,9 +44,67 @@ WHERE model_model = ?
|
||||
"""
|
||||
|
||||
|
||||
def _resolve_day_fase(descriptions: set[str]) -> str | None:
|
||||
"""FASE5 beats FASE2; anything else is ignored."""
|
||||
upper = {d.upper() for d in descriptions if d}
|
||||
@dataclass
|
||||
class _DayRecords:
|
||||
descriptions: set[str] = field(default_factory=set)
|
||||
incub_values: list[Any] = field(default_factory=list)
|
||||
|
||||
|
||||
def _is_incubazprimaria(label: str | None) -> bool:
|
||||
return bool(label) and label.upper().startswith("INCUBAZPRIMARIA")
|
||||
|
||||
|
||||
def _format_model_value(value: Any) -> str | None:
|
||||
"""Compact decimal string: 40.3, not 40.3000000001 or 4.03e+01."""
|
||||
if value is 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:
|
||||
text = format(Decimal(text), "f")
|
||||
except (InvalidOperation, ValueError):
|
||||
return text
|
||||
if "." in text:
|
||||
text = text.rstrip("0").rstrip(".")
|
||||
return text or None
|
||||
|
||||
|
||||
def _incub_label(values: list[Any]) -> str:
|
||||
"""INCUBAZPRIMARIA_<highest model_value>, or bare INCUBAZPRIMARIA if none."""
|
||||
numerics: list[tuple[float, Any]] = []
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
numerics.append((float(value), value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if numerics:
|
||||
best = max(numerics, key=lambda item: item[0])[1]
|
||||
formatted = _format_model_value(best)
|
||||
if formatted:
|
||||
return f"INCUBAZPRIMARIA_{formatted}"
|
||||
return "INCUBAZPRIMARIA"
|
||||
|
||||
|
||||
def _resolve_day_fase(records: _DayRecords) -> str | None:
|
||||
"""
|
||||
INCUBAZPRIMARIA beats FASE5 beats FASE2; anything else is ignored.
|
||||
|
||||
Incubation days are stored as INCUBAZPRIMARIA_<model_value> (e.g.
|
||||
INCUBAZPRIMARIA_40.3) from the tmp2 row. Several incubation rows on one
|
||||
day keep the highest numeric model_value. A missing model_value falls
|
||||
back to bare INCUBAZPRIMARIA.
|
||||
"""
|
||||
upper = {d.upper() for d in records.descriptions if d}
|
||||
if any("INCUBAZPRIMARIA" in d for d in upper):
|
||||
return _incub_label(records.incub_values)
|
||||
if "FASE5" in upper:
|
||||
return "FASE5"
|
||||
if "FASE2" in upper:
|
||||
@ -106,7 +167,7 @@ def resolve_disease_forecast_by_anmod(
|
||||
start, end = window[0], window[-1]
|
||||
rows = fetch_all(conn, _TMP2_SQL, (anmod_id, start, end))
|
||||
|
||||
by_day: dict[date, set[str]] = {d: set() for d in window}
|
||||
by_day: dict[date, _DayRecords] = {d: _DayRecords() for d in window}
|
||||
stations: list[int] = []
|
||||
for row in rows:
|
||||
day = row.model_day
|
||||
@ -115,13 +176,16 @@ def resolve_disease_forecast_by_anmod(
|
||||
day = date(day.year, day.month, day.day)
|
||||
if day not in by_day:
|
||||
continue
|
||||
if row.model_description:
|
||||
by_day[day].add(str(row.model_description).strip())
|
||||
description = str(row.model_description).strip() if row.model_description else ""
|
||||
if description:
|
||||
by_day[day].descriptions.add(description)
|
||||
if "INCUBAZPRIMARIA" in description.upper():
|
||||
by_day[day].incub_values.append(row.model_value)
|
||||
if row.model_station is not None:
|
||||
stations.append(int(row.model_station))
|
||||
|
||||
forecasts: dict[date, str | None] = {
|
||||
day: _resolve_day_fase(descs) for day, descs in by_day.items()
|
||||
day: _resolve_day_fase(records) for day, records in by_day.items()
|
||||
}
|
||||
|
||||
first_future: date | None = None
|
||||
@ -144,29 +208,51 @@ def resolve_disease_forecast_by_anmod(
|
||||
)
|
||||
|
||||
|
||||
def nearest_fase(
|
||||
def select_prefilter_rule(
|
||||
forecasts_by_day: dict[date, str | None],
|
||||
observation_by_day: dict[date, bool],
|
||||
as_of: date,
|
||||
) -> str | None:
|
||||
"""
|
||||
Find the FASE closest to as_of, preferring today/future over past.
|
||||
Choose which product prefilter branch applies (Part_One, step 3).
|
||||
|
||||
Used by the product prefilter (Part_One, step 3).
|
||||
"Past/today" is as_of-5 .. as_of; "future" is as_of+1 .. as_of+5. First
|
||||
match wins:
|
||||
|
||||
1. `observation` True on as_of or any of the past 5 days -> "observation"
|
||||
2. FASE2 anywhere in the window AND no FASE5 anywhere -> "fase2_only"
|
||||
3. FASE5 present both past/today AND future -> "fase5_both"
|
||||
4. FASE5 present in the future only -> "fase5_future"
|
||||
5. FASE5 present today or in the past only -> "fase5_past"
|
||||
6. INCUBAZPRIMARIA today or in the past 5 days -> "incubazprimaria"
|
||||
7. otherwise -> None
|
||||
|
||||
None means the product prefilter applies only the crop / disease /
|
||||
organic gates, with no FASE-specific catalog cut.
|
||||
"""
|
||||
if forecasts_by_day.get(as_of) is not None:
|
||||
return forecasts_by_day[as_of]
|
||||
past_today = [d for d in forecasts_by_day if d <= as_of]
|
||||
future = [d for d in forecasts_by_day if d > as_of]
|
||||
|
||||
future = sorted(
|
||||
(d for d, v in forecasts_by_day.items() if d > as_of and v is not None),
|
||||
)
|
||||
if future:
|
||||
return forecasts_by_day[future[0]]
|
||||
if any(observation_by_day.get(d, False) for d in past_today):
|
||||
return "observation"
|
||||
|
||||
past = sorted(
|
||||
(d for d, v in forecasts_by_day.items() if d < as_of and v is not None),
|
||||
reverse=True,
|
||||
)
|
||||
if past:
|
||||
return forecasts_by_day[past[0]]
|
||||
fase2_anywhere = any(v == "FASE2" for v in forecasts_by_day.values())
|
||||
fase5_anywhere = any(v == "FASE5" for v in forecasts_by_day.values())
|
||||
|
||||
if fase2_anywhere and not fase5_anywhere:
|
||||
return "fase2_only"
|
||||
|
||||
fase5_past_today = any(forecasts_by_day.get(d) == "FASE5" for d in past_today)
|
||||
fase5_future = any(forecasts_by_day.get(d) == "FASE5" for d in future)
|
||||
|
||||
if fase5_past_today and fase5_future:
|
||||
return "fase5_both"
|
||||
if fase5_future:
|
||||
return "fase5_future"
|
||||
if fase5_past_today:
|
||||
return "fase5_past"
|
||||
|
||||
if any(_is_incubazprimaria(forecasts_by_day.get(d)) for d in past_today):
|
||||
return "incubazprimaria"
|
||||
|
||||
return None
|
||||
|
||||
61
pipeline/stages/observation.py
Normal file
61
pipeline/stages/observation.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""Part_One, step 3: pathogen observation factor for as_of and the past 5 days."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pyodbc
|
||||
|
||||
from pipeline.db import fetch_all
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OBSERVATION_SQL = """
|
||||
SELECT rilpato_date, rilpato_diffusion
|
||||
FROM AI_agrosupport_agro_ril_pathogen
|
||||
WHERE rilpato_layer = ?
|
||||
AND rilpato_date BETWEEN ? AND ?
|
||||
"""
|
||||
|
||||
|
||||
def _to_date(value: Any) -> date:
|
||||
if hasattr(value, "date") and callable(value.date):
|
||||
value = value.date()
|
||||
return date(value.year, value.month, value.day)
|
||||
|
||||
|
||||
def load_observation(
|
||||
conn: pyodbc.Connection,
|
||||
field_id: int,
|
||||
as_of: date,
|
||||
) -> dict[date, bool]:
|
||||
"""
|
||||
Return the observation flag per day over as_of-5 .. as_of.
|
||||
|
||||
True for a day if any AI_agrosupport_agro_ril_pathogen row for that field
|
||||
and date has rilpato_diffusion other than 32, including NULL. Days with
|
||||
no matching row are False.
|
||||
"""
|
||||
start = as_of - timedelta(days=5)
|
||||
rows = fetch_all(conn, _OBSERVATION_SQL, (field_id, start, as_of))
|
||||
|
||||
result: dict[date, bool] = {start + timedelta(days=i): False for i in range(6)}
|
||||
for row in rows:
|
||||
if row.rilpato_date is None:
|
||||
continue
|
||||
day = _to_date(row.rilpato_date)
|
||||
if day not in result:
|
||||
continue
|
||||
diffusion = row.rilpato_diffusion
|
||||
if diffusion is None or int(diffusion) != 32:
|
||||
result[day] = True
|
||||
|
||||
logger.info(
|
||||
"Observation for field %s: %d/%d day(s) true",
|
||||
field_id,
|
||||
sum(result.values()),
|
||||
len(result),
|
||||
)
|
||||
return result
|
||||
@ -30,6 +30,7 @@ class ProductRow:
|
||||
organic_certified: bool
|
||||
systemicity: str # stripped, original casing (used verbatim in the Product JSON payload)
|
||||
preventive_action: bool
|
||||
curative_action: bool
|
||||
eradicant_action: bool
|
||||
target_crops: list[str]
|
||||
target_diseases: list[str]
|
||||
@ -52,6 +53,7 @@ SELECT
|
||||
organic_certified,
|
||||
systemicity,
|
||||
preventive_action,
|
||||
curative_action,
|
||||
eradicant_action,
|
||||
target_crops,
|
||||
target_diseases,
|
||||
@ -105,6 +107,7 @@ class ProductIndex:
|
||||
organic_certified=bool(r.organic_certified),
|
||||
systemicity=(r.systemicity or "").strip(),
|
||||
preventive_action=bool(r.preventive_action),
|
||||
curative_action=bool(r.curative_action),
|
||||
eradicant_action=bool(r.eradicant_action),
|
||||
target_crops=parse_json_list(r.target_crops),
|
||||
target_diseases=parse_json_list(r.target_diseases),
|
||||
@ -128,13 +131,23 @@ def prefilter_products(
|
||||
crop_english: str,
|
||||
disease_english: str,
|
||||
organic: bool,
|
||||
fase: str | None,
|
||||
rule: str | None,
|
||||
) -> list[ProductCandidate]:
|
||||
"""
|
||||
Filter the (crop, disease) bucket by organic flag and FASE rules.
|
||||
Filter the (crop, disease) bucket by organic flag and the selected
|
||||
prefilter rule (see pipeline.stages.disease.select_prefilter_rule).
|
||||
|
||||
FASE2 → systemicity in (systemic, mixed) AND preventive_action = 1
|
||||
FASE5 → eradicant_action = 0
|
||||
observation -> systemicity in {systemic, mixed} AND eradicant_action = 1
|
||||
fase2_only -> systemicity == contact AND preventive_action = 1
|
||||
AND curative_action = 0 AND eradicant_action = 0
|
||||
fase5_both -> systemicity == mixed AND preventive_action = 1
|
||||
AND curative_action = 1 AND eradicant_action = 0
|
||||
fase5_future -> same predicates as fase2_only
|
||||
fase5_past -> systemicity in {contact, mixed} AND preventive_action = 1
|
||||
AND curative_action = 0 AND eradicant_action = 0
|
||||
incubazprimaria -> systemicity in {systemic, mixed} AND curative_action = 1
|
||||
AND eradicant_action = 0
|
||||
None -> no FASE-specific cut; crop/disease/organic gates only
|
||||
"""
|
||||
candidates: list[ProductCandidate] = []
|
||||
|
||||
@ -142,23 +155,57 @@ def prefilter_products(
|
||||
if organic and not row.organic_certified:
|
||||
continue
|
||||
|
||||
if fase == "FASE2":
|
||||
if row.systemicity.lower() not in {"systemic", "mixed"}:
|
||||
systemicity = row.systemicity.lower()
|
||||
|
||||
if rule == "observation":
|
||||
if systemicity not in {"systemic", "mixed"}:
|
||||
continue
|
||||
if not row.eradicant_action:
|
||||
continue
|
||||
elif rule in ("fase2_only", "fase5_future"):
|
||||
if systemicity != "contact":
|
||||
continue
|
||||
if not row.preventive_action:
|
||||
continue
|
||||
elif fase == "FASE5":
|
||||
if row.curative_action:
|
||||
continue
|
||||
if row.eradicant_action:
|
||||
continue
|
||||
elif rule == "fase5_both":
|
||||
if systemicity != "mixed":
|
||||
continue
|
||||
if not row.preventive_action:
|
||||
continue
|
||||
if not row.curative_action:
|
||||
continue
|
||||
if row.eradicant_action:
|
||||
continue
|
||||
elif rule == "fase5_past":
|
||||
if systemicity not in {"contact", "mixed"}:
|
||||
continue
|
||||
if not row.preventive_action:
|
||||
continue
|
||||
if row.curative_action:
|
||||
continue
|
||||
if row.eradicant_action:
|
||||
continue
|
||||
elif rule == "incubazprimaria":
|
||||
if systemicity not in {"systemic", "mixed"}:
|
||||
continue
|
||||
if not row.curative_action:
|
||||
continue
|
||||
if row.eradicant_action:
|
||||
continue
|
||||
# rule is None: no FASE-specific cut, only the gates above apply.
|
||||
|
||||
candidates.append(row.as_candidate())
|
||||
|
||||
logger.info(
|
||||
"Product prefilter: crop=%s disease=%s organic=%s fase=%s -> %d candidates",
|
||||
"Product prefilter: crop=%s disease=%s organic=%s rule=%s -> %d candidates",
|
||||
crop_english,
|
||||
disease_english,
|
||||
organic,
|
||||
fase,
|
||||
rule,
|
||||
len(candidates),
|
||||
)
|
||||
return candidates
|
||||
|
||||
@ -3,5 +3,7 @@ You are a decision support system to prevent primary grapevine downy mildew (Pla
|
||||
PREVIOUS KNOWLEDGE ON THE MODEL'S BEHAVIOR: The output of the model is definite. On the days marked as FASE5, conditions are favorable for the infection of zoospores on leaf surfaces and their penetration into the leaves through the stomata.
|
||||
FASE5 of the grapevine downy mildew phytopathological model refers to the critical primary infection phase. This process occurs following the dispersion of infective zoospores, which reach the leaf surfaces and successfully penetrate through the stomata. By establishing this initial infection in the first green tissues, Phase 5 marks the decisive moment that triggers the seasonal epidemic dynamics and subsequent epidemic attacks.
|
||||
In FASE2, the germination of Plasmopara viticola oospores involves the formation of a macrosporangium from which the infective zoospores are released, a process favored by high humidity and mild spring temperatures. The associated risk is the onset of primary downy mildew infections, especially following prolonged rainfall and in the presence of susceptible young foliage.
|
||||
INCUBAZPRIMARIA is the progress percentage of the incubation process associated with a specific infection event. Incubation begins only when the temperature conditions required for the infection to establish are met, progressing gradually until reaching 100%, at which point symptom onset is expected. In the absence of suitable conditions, the incubation process may fail to start or may be interrupted. If the value for the disease_forecast key in the JSON is INCUBAZPRIMARIA_40, the 40 after the underscore represents 40%.
|
||||
If observation = TRUE, it represents direct field evidence of infection and the risk of further spread, as observed on-site by the farmer or technician.
|
||||
ADDITIONAL NOTES: Main grapevine phenology phases are: BBCH < 53: leaf development, BBCH < 60: inflorescence emerge; BBCH < 70: flowering BBCH < 80: fruits development, BBCH < 90: ripening. You are smart, wise, nice, honest, and determined to promote Integrated Pest Management practices. ho cambiato idea, scrivilo in Italiano.
|
||||
STRICT RULES: 1. Stick only to the output of the model for any interpreting or explanation. 2. If there is no necessity for a treatment, don't discuss the probable future situation may need a treatment or the dosage.
|
||||
STRICT RULES: 1. Stick only to the output of the model for any interpreting or explanation. 2. If there is no necessity for a treatment, don't discuss the probable future situation that may need a treatment or the dosage.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user