Weather Window Logic
Weather window logic is the atmospheric-compliance gate of a crop application timing engine: it turns noisy, heterogeneous meteorological telemetry into a single defensible answer — is it legal and effective to spray this pass right now, and with what confidence? This subsystem sits inside the Crop Application Timing & Agronomic Validation engine and supplies the go/no-go window that dispatch reads after the phenology and buffer gates have cleared. Wind speed, relative humidity, temperature inversion risk, and precipitation probability are the four levers that decide whether a chemical lands on target or drifts onto a neighbouring block, and getting the reading wrong is never a crash — it is a plausible-but-illegal record that surfaces during an inspection.
Problem framing: an open window is a legal assertion, not a suggestion
When the engine declares a window open it is asserting that the atmosphere at spray time satisfied every label constraint on the product in the tank. That assertion is expensive to get wrong in both directions. Clear a contact herbicide during a low-wind temperature inversion and the fine droplets hang suspended, then move laterally as the inversion breaks, landing off-target hours later and triggering a Federal Insecticide, Fungicide, and Rodenticide Act violation, a buyer-audit finding, and a defensible-record gap. Hold too conservatively — closing the window on a single gusty reading — and you strand a valid application interval, waste an operator shift, and erode trust in the automation until crews start spraying on gut feel and bypassing the gate entirely.
The failure is asymmetric and quiet. A ten-minute wind spike does not stop the sprayer; it produces a record that looks fine until an auditor overlays the application timestamp on the nearest station’s archive and finds the wind was 9 m/s when the label capped at 4. The hard part is that the inputs are continuous, asynchronous, and disagree with each other: an on-boom anemometer, a field microclimate station, and a gridded forecast API rarely agree to the second, and any one of them can drift or drop out mid-pass. This subsystem exists to reconcile those feeds into a stable window decision, stamp it with the exact readings and thresholds that produced it, and re-check the atmosphere continuously while the boom is live rather than trusting a stale approval. The tighter, retrospective treatment of historical archives and interpolation edge cases lives in Calculating optimal spray windows using historical weather; this page is the end-to-end reference for the live window service itself.
Prerequisites and dependencies
The service depends on three upstream data contracts and a small, pinned Python stack. Confirm all of them before wiring window output into the dispatch layer.
- Normalized weather telemetry. Time-series wind speed, relative humidity, air temperature at two heights (for inversion detection), and precipitation probability, each tagged with the field polygon and station they belong to, arrive already parsed from the Weather API Integration service in the Farm Data Ingestion & Field Boundary Synchronization pipeline. Raw on-boom sensor payloads are resolved to a field zone by Equipment Telemetry Parsing before they reach this gate, and long-poll refresh cadence is governed by Async Polling Strategies.
- A confirmed phenological stage. The window gate reads the current BBCH code from Growth Stage Mapping so it can apply stage-specific tolerances — a pre-emergence residual has different drift sensitivity than a post-canopy fungicide.
- The active chemical profile and its label limits. Per-product wind, humidity, and inversion caps are versioned in the EPA/USDA Rule Mapping threshold set and retuned through Threshold Tuning, never hard-coded here. When a window closes, the blocked application is routed by Fallback Application Chains.
Library versions are pinned so window decisions are reproducible across nodes: pandas>=2.0 for time-series resampling, pydantic>=2.0 for payload schema validation, and Python 3.10+ for match/case and the union type syntax used below. Threshold values follow the product label and EPA pesticide label requirements; historical archives for retrospective tuning come from the National Centers for Environmental Information, and structured audit logging follows the Python logging documentation.
Architecture of this subsystem
The service is a three-stage transform: heterogeneous weather telemetry is normalized to a consistent sub-hourly cadence, the latest reconciled reading is evaluated against label thresholds into an open/closed window with a confidence score, then a rolling loop re-validates the atmosphere while the boom is live and freezes each decision into the audit ledger. Each stage emits a structured log line carrying a correlation_id, so a single window evaluation is traceable end to end, and each stage has an explicit conservative fallback — a safe-hold — rather than an exception that abandons a live decision.
The inputs are a per-field weather DataFrame, the active chemical profile, and the confirmed BBCH stage; the output is a window decision (OPEN/CLOSED), a viability score, and a metadata block. The integration points are strictly one-directional: this service consumes normalized weather and label limits and produces a window signal that Buffer Zone Calculations and the dispatch layer consume — it never writes back to the sibling subsystems.
Step-by-step implementation
Step 1 — Ingest and normalize weather telemetry
Reliable window computation begins with deterministic ingestion. Field anemometers, microclimate stations, and forecast APIs deliver heterogeneous payloads on different clocks, so every stream is converted to UTC, resampled to a fixed sub-hourly cadence, forward-filled only within an agronomic tolerance window, and screened for sensor drift before any threshold logic runs. Missing readings must never be silently invented; a gap beyond tolerance holds the window rather than guessing.
| Parameter | Type | Default | Purpose |
|---|---|---|---|
raw_payload |
dict[str, Any] |
— | Raw station/forecast payload with a readings list |
| resample cadence | str |
"15min" |
Fixed interval all feeds are aligned to before evaluation |
| forward-fill limit | int |
4 intervals (1 h) |
Longest gap bridged before the window holds |
| drift threshold | float |
3.0 sigma |
Z-score above which a channel is treated as sensor drift |
import logging
import pandas as pd
from typing import Optional, Any
audit_logger = logging.getLogger("weather_ingestion")
audit_logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
audit_logger.addHandler(handler)
def load_cached_baseline() -> Optional[pd.DataFrame]:
"""Return the last known-good weather baseline, or None if unavailable."""
# Implementation: load from local cache file or in-memory store.
return None
def activate_offline_fallback() -> Optional[pd.DataFrame]:
"""Enter conservative offline scheduling mode when ingestion cannot recover."""
audit_logger.critical("Offline fallback activated; manual review required")
return None
def ingest_meteorological_telemetry(raw_payload: dict[str, Any]) -> Optional[pd.DataFrame]:
"""
Ingest raw weather telemetry, normalize timestamps to UTC, bridge only short
gaps, screen for sensor drift, and route corrupted payloads to a safe fallback.
"""
try:
df = pd.DataFrame(raw_payload.get("readings", []))
if df.empty:
audit_logger.warning("Empty payload received; falling back to cached baseline")
return load_cached_baseline()
# Strict UTC normalization and monotonic ordering.
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
df = df.set_index("timestamp").sort_index()
# Align every feed to a 15-minute cadence; bridge at most 1 hour of gap.
df = df.resample("15min").ffill(limit=4)
# Flag sensor drift with a per-channel Z-score before it corrupts logic.
for col in df.select_dtypes(include="number").columns:
mean, std = df[col].mean(), df[col].std()
if std > 0:
mask = (df[col] - mean).abs() > 3 * std
if mask.any():
audit_logger.warning("Sensor drift detected in %s; applying median imputation", col)
df.loc[mask, col] = df[col].median()
audit_logger.info("Normalized %d telemetry records", len(df))
return df
except Exception as exc: # noqa: BLE001 - last-resort guard keeps a live decision safe
audit_logger.error("Ingestion failure: %s; routing to offline compliance mode", exc)
return activate_offline_fallback()
Expected log output on a healthy ingest with one drifting channel:
2026-07-02 13:00:04 | WARNING | Sensor drift detected in wind_speed_mps; applying median imputation
2026-07-02 13:00:04 | INFO | Normalized 96 telemetry records
Step 2 — Evaluate the reading against label thresholds
The validation engine scores the latest reconciled reading against the chemical’s label limits and the confirmed BBCH stage. Wind is the dominant drift driver, so it carries the heaviest weight; relative humidity governs droplet evaporation and secondary drift. The function returns a boolean viability flag, a confidence score, and the exact parameters it read, so the decision is reconstructable. A match statement resolves the compliance disposition from the score, keeping the branch readable as more conditions are added.
| Parameter | Type | Default | Purpose |
|---|---|---|---|
telemetry_df |
pd.DataFrame |
— | Normalized output of Step 1 |
chemical_profile |
dict[str, float] |
— | Per-product label limits from the versioned threshold set |
max_wind_mps |
float |
4.0 |
Upper wind bound before drift risk closes the window |
min_rh_pct |
float |
30.0 |
Lower humidity bound; below it fine droplets evaporate |
max_rh_pct |
float |
85.0 |
Upper humidity bound; above it deposition and runoff rise |
| viability floor | float |
0.8 |
Weighted score at or above which the window opens |
from typing import Any
def evaluate_weather_window(
telemetry_df: pd.DataFrame,
chemical_profile: dict[str, float],
max_wind_mps: float = 4.0,
min_rh_pct: float = 30.0,
max_rh_pct: float = 85.0,
) -> dict[str, Any]:
"""
Evaluate the latest reading against agronomic thresholds, returning a window
decision with a confidence score and a safe-hold fallback on missing data.
"""
try:
latest = telemetry_df.iloc[-1]
wind_ok = latest.get("wind_speed_mps", float("inf")) <= max_wind_mps
rh_val = latest.get("relative_humidity_pct", 0.0)
rh_ok = min_rh_pct <= rh_val <= max_rh_pct
# Wind is the primary drift driver, so it dominates the weighted score.
viability_score = (1.0 if wind_ok else 0.0) * 0.6 + (1.0 if rh_ok else 0.0) * 0.4
is_viable = viability_score >= 0.8
match is_viable:
case True:
compliance_status = "APPROVED"
case False:
compliance_status = "RESTRICTED"
audit_logger.info(
"Window evaluation | wind_mps=%.2f rh_pct=%.1f viability=%.2f status=%s",
latest.get("wind_speed_mps", float("nan")),
rh_val,
viability_score,
"OPEN" if is_viable else "CLOSED",
)
return {
"is_viable": is_viable,
"viability_score": viability_score,
"parameters_logged": latest.to_dict(),
"compliance_status": compliance_status,
}
except IndexError:
audit_logger.error("No telemetry available; attempting historical baseline")
baseline = load_cached_baseline()
if baseline is not None and not baseline.empty:
return evaluate_weather_window(baseline, chemical_profile, max_wind_mps, min_rh_pct, max_rh_pct)
return {"is_viable": False, "viability_score": 0.0, "compliance_status": "HOLD"}
except Exception as exc: # noqa: BLE001 - a failed evaluation must default to safe-hold
audit_logger.critical("Validation engine failure: %s; defaulting to safe-hold", exc)
return {"is_viable": False, "viability_score": 0.0, "compliance_status": "HOLD"}
Expected log output for a window that closes on wind:
2026-07-02 13:15:01 | INFO | Window evaluation | wind_mps=6.30 rh_pct=52.0 viability=0.40 status=CLOSED
Step 3 — Re-validate during execution and freeze the decision
A window that was open when the tank was filled can close before the pass finishes. GPS drift, boom-pressure fluctuation, and a rising afternoon breeze introduce micro-latencies that invalidate a stale approval, so the tracker re-evaluates conditions at sub-minute intervals while the boom is live and writes every execution event to an append-only ledger. A single spike does not halt the pass — a sustained breach does — which mirrors the hysteresis discipline the phenology gate uses to suppress flapping.
| Parameter | Type | Default | Purpose |
|---|---|---|---|
audit_path |
Path |
execution_audit.jsonl |
Append-only ledger file for execution events |
event |
dict[str, Any] |
— | The field execution event to record |
fallback_chain |
Callable | None |
None |
Route invoked when the ledger write fails |
import json
from pathlib import Path
from datetime import datetime, timezone
from typing import Any, Callable, Optional
class ApplicationTracker:
def __init__(self, audit_path: Path = Path("/var/log/agronomic/execution_audit.jsonl")):
self.audit_path = audit_path
self.audit_path.parent.mkdir(parents=True, exist_ok=True)
def log_execution_event(
self,
event: dict[str, Any],
fallback_chain: Optional[Callable[[dict[str, Any]], None]] = None,
) -> None:
"""Append a field execution event to the immutable ledger with safe fallback routing."""
try:
event["timestamp_utc"] = datetime.now(timezone.utc).isoformat()
event["compliance_verified"] = True
with open(self.audit_path, "a") as fh:
fh.write(json.dumps(event) + "\n")
audit_logger.info("Execution event logged: %s", event.get("operation_id"))
except IOError as exc:
audit_logger.warning("Audit write failed: %s; triggering fallback chain", exc)
if fallback_chain:
fallback_chain(event)
else:
audit_logger.critical("No fallback chain defined; event queued in memory buffer")
except Exception as exc: # noqa: BLE001 - a compromised ledger must fail loud
audit_logger.error("Tracking failure: %s; entering safe mode", exc)
raise RuntimeError("Tracking subsystem compromised") from exc
Expected log output as an open window is confirmed at execution time:
2026-07-02 13:16:00 | INFO | Execution event logged: OP-2026-0712-A
Edge cases and known failure modes
| Condition | Symptom | Fix |
|---|---|---|
| On-boom anemometer drifts against station | Viability inflates or deflates against reality; wrong open/closed calls | Blend independent feeds and Z-score each channel (Step 1); alert when one diverges from the median. |
| Multi-interval forecast-API outage | Window freezes on stale data or ffill masks the gap | Bridge only within the forward-fill limit (Step 1); beyond it, hold the window rather than guessing. |
| Temperature inversion at low wind | Low wind reads “safe” while fine droplets hang and drift laterally | Gate on the two-height temperature delta, not wind alone; treat inversion as an independent CLOSED trigger. |
| Empty or malformed payload on reconnect | Evaluation raises before it scores anything | Empty payload returns the cached baseline (Step 1); a missing latest row returns safe-hold (Step 2). |
| Duplicate reading replayed on reconnect | Two rows for one interval double-count the latest reading | Resample by fixed cadence (Step 1) so a replay reconciles to one value before evaluation. |
| Gusty single-interval spike mid-pass | Window flaps CLOSED then OPEN, aborting a valid pass | Require a sustained breach in the rolling loop (Step 3), not a single spike, before halting. |
| Ledger disk full during a live pass | Execution event is lost silently | IOError triggers the fallback chain or an in-memory buffer (Step 3); the event is never dropped unlogged. |
Compliance and audit integration
Every window decision is a compliance artifact, because it is the fact that licenses or blocks an application under the product label. Each stage of the transform feeds the append-only audit ledger described in the parent engine, and three facts must survive from computation to inspection: the open/closed decision and its viability score, the exact reading (wind, humidity, inversion state, precipitation probability) that produced it, and the threshold_set_version the label limits came from. Because those limits originate in the EPA/USDA Rule Mapping threshold set and are stamped onto the record, retuning a threshold never rewrites history — a window that cleared last season replays against the limits that were in force at the time.
The immutability guarantee is what makes a record defensible. Ledger entries are hash-anchored and append-only; a window decision is never edited in place, only superseded by a later re-validation with its own correlation_id. Because drift and off-target deposition are governed by the product label under FIFRA, an auditor can trace any cleared application back to the exact atmospheric reading and thresholds that authorized it, satisfying the traceability expectations of digital-agriculture compliance frameworks such as the FAO digital agriculture guidelines.
Verification
Confirm correct operation in staging with a reproducible fault-injection scenario before promoting a change:
- Golden-day replay. Feed a full known-good day of 15-minute telemetry with a fixed threshold set and assert the open/closed timeline matches the expected window sequence interval-for-interval. A weighting or threshold regression fails this immediately.
- Inject an out-of-range reading. Push a wind speed of
-3.0m/s into Step 1 and assert it is flagged as drift, a warning is logged, and the median imputation leaves the window decision unaffected. - Force a telemetry gap. Blank five consecutive intervals of wind and assert the forward-fill limit is exceeded, the window holds on its last value rather than advancing, and no OPEN is emitted on stale data.
- Trigger a mid-pass spike. Alternate a single gusty reading against calm intervals during a live pass and assert the rolling loop suppresses the flap until a sustained breach appears, with one traceable ledger entry per confirmed decision.
A run passes only when each injected fault produces the expected degraded-but-safe result — a hold or a safe-hold, never a spurious OPEN — and the ledger contains one traceable entry per decision. Deeper retrospective and historical-archive debugging cases are covered in Calculating optimal spray windows using historical weather.
Frequently Asked Questions
Why weight wind more heavily than humidity in the viability score? Wind is the primary driver of spray drift — it moves droplets off-target directly — while humidity governs the slower, secondary path of droplet evaporation and fine-particle suspension. Weighting wind at 0.6 against humidity at 0.4 means a wind breach alone can close the window, but a marginal humidity reading cannot open it on its own.
Why re-validate during the pass instead of trusting the approval at tank-fill? An approval captured minutes earlier can be stale by the time the boom reaches the far end of the field. Rising afternoon wind, an inversion breaking, or boom-pressure change can all invalidate the original reading, so the rolling loop re-checks the atmosphere at sub-minute intervals and halts on a sustained breach rather than a single spike.
What happens when the weather feed drops out mid-window? Short gaps are bridged only within the forward-fill limit; anything longer holds the window on its last confirmed value. A missing wind or humidity reading can only keep a window closed or hold it — it can never force a window open on partial evidence, so a feed dropout degrades safely to a no-go.
How does a window decision stay reproducible after label limits are retuned?
Every decision stores the reading, the viability score, and the threshold_set_version its limits came from. Threshold sets are append-only and versioned, so replaying an old evaluation reconstructs the exact window that cleared at the time even after wind or humidity caps have been recalibrated.
Related
- Calculating optimal spray windows using historical weather — retrospective archive, interpolation, and safe-override debugging reference for this service.
- Growth Stage Mapping — supplies the confirmed BBCH stage this gate reads to set stage-specific tolerances.
- Buffer Zone Calculations — consumes the open window and enforces the drift setbacks that atmospheric conditions inform.
- Threshold Tuning — versions the wind, humidity, and inversion limits this service evaluates against.
- Fallback Application Chains — routes a blocked application when the window closes.
- Weather API Integration — the upstream feed that supplies the normalized telemetry evaluated here.