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.

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.

Weather window logic service pipeline Left-to-right pipeline of three transform stages producing an open/closed window decision. Stage 1 (ingest and normalize) reconciles on-boom anemometer, field microclimate station and gridded forecast feeds into a UTC 15-minute-cadence DataFrame with outlier and drift flags. Stage 2 (threshold validation) scores the latest reading against chemical label limits and the confirmed BBCH stage into a weighted viability score and an OPEN or CLOSED window. Stage 3 (execution re-validation) re-checks conditions at sub-minute intervals while the boom is live and safe-holds on a sustained breach. Inbound signals: Weather API Integration supplies normalized telemetry to stage 1; Growth Stage Mapping supplies the BBCH stage and EPA/USDA Rule Mapping supplies the versioned label limits to stage 2. The window decision carries a viability score, the exact reading and the threshold-set version. An OPEN window feeds the dispatch and Buffer Zone gate; a CLOSED window branches down to Fallback Application Chains. Each stage also emits a dashed branch into an append-only, hash-anchored audit ledger keyed by one correlation_id per stage. Weather API Integration normalized telemetry Growth Stage Mapping BBCH stage EPA/USDA Rule Mapping versioned label limits 1 · Ingest & normalize anemometer · station · gridded forecast → UTC 15-min DataFrame 2 · Threshold validation reading × limits × BBCH weighted viability score → window OPEN / CLOSED 3 · Execution re-validation sub-minute re-check while boom is live → sustained-breach hold Window decision score · reading · version OPEN → dispatch gate → Buffer Zone gate CLOSED OPEN Fallback Application Chains routes the blocked pass Append-only audit ledger hash-anchored · one correlation_id per stage

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
python
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:

text
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
python
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:

text
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
python
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:

text
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:

  1. 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.
  2. Inject an out-of-range reading. Push a wind speed of -3.0 m/s into Step 1 and assert it is flagged as drift, a warning is logged, and the median imputation leaves the window decision unaffected.
  3. 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.
  4. 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.

Up: Crop Application Timing & Agronomic Validation