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:
Arsham Mirehvandi 2026-09-05 23:02:39 +02:00
parent 21927133a4
commit 98afec550d
7 changed files with 288 additions and 63 deletions

View File

@ -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 12 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 12 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

View File

@ -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,
)

View File

@ -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
]

View File

@ -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

View 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

View File

@ -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

View File

@ -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.