Growth Stage Mapping

Growth stage mapping is the phenological gate of a crop application timing engine: it turns raw, noisy field telemetry into a single defensible answer — which BBCH stage is this crop in right now, and with what confidence? Every downstream timing decision depends on that answer, because chemical efficacy and phytotoxicity are governed by the plant’s physiological stage, not the calendar. This subsystem sits inside the Crop Application Timing & Agronomic Validation engine and supplies the stage signal that the weather, buffer, and dispatch layers all read before any equipment is cleared.

Problem framing: a wrong stage is a quiet, expensive error

A misclassified growth stage does not crash anything. It produces a plausible-but-wrong record that clears an application which should have been blocked, and the cost surfaces weeks later as crop injury, a wasted input, or a residue-compliance gap during a buyer audit. Spray a growth regulator one BBCH stage too early and it is agronomically inert; apply a contact herbicide after the target tissue has hardened off and it under-performs while the residue clock still starts. The failure is asymmetric: promoting a stage too eagerly clears premature applications, while promoting too conservatively strands a valid application window and erodes operator trust until crews start overriding the automation.

The hard part is that the inputs are continuous, noisy, and asynchronous, while the output is a discrete, ordered, monotonic state. NDVI drifts with cloud cover and sensor calibration, canopy-height estimates jitter with wind, and scout logs arrive days late. A naive classifier that thresholds a single instantaneous reading will flap between stages on noise alone, firing and retracting alerts and corrupting the audit trail. This subsystem exists to compute a stable, hysteresis-guarded stage estimate, stamp it with the inputs and thresholds that produced it, and hand it to the timing engine deterministically. The tighter, debugging-first treatment of BBCH state transitions and alert flapping lives in Mapping BBCH growth stages to automated alerts; this page is the end-to-end reference for the mapping 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 stage output into the timing engine.

  • Normalized field telemetry. Time-series NDVI, canopy height, and thermal-stress indices, plus the field polygon they belong to, arrive already parsed and geo-referenced from the Farm Data Ingestion & Field Boundary Synchronization pipeline. Raw implement and sensor payloads are resolved to a field zone by Equipment Telemetry Parsing before they reach this stage.
  • A ground-truth planting record. Emergence date, seed lot, and management-zone map anchor accumulated growing-degree-day (GDD) estimates. Without it the state machine cannot reconcile sensor-inferred stages against expected phenology and will over-trust noisy imagery.
  • Weather-derived heat units. Daily minimum and maximum temperatures used to accumulate GDD come from the same forecast and observation feeds that drive Weather Window Logic, sourced upstream through Weather API Integration. The transition thresholds themselves are versioned in the EPA/USDA Rule Mapping and Threshold Tuning layers, never hard-coded here.

Library versions are pinned so stage inference is 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. The phenological reference is the standardized BBCH scale for cereals, oilseeds, and specialty crops, cross-checked against USDA NRCS crop and phenology resources and the FAO crop calendar. Structured audit logging follows the Python logging documentation.

Architecture of this subsystem

The service is a three-stage transform: heterogeneous telemetry is normalized to a consistent daily cadence, continuous metrics are mapped onto candidate BBCH codes, then a hysteresis state machine confirms or defers a transition before the confirmed stage is frozen into the audit ledger and read by the timing engine. Each stage emits a structured log line carrying a correlation_id, so a single evaluation is traceable end to end, and each stage has an explicit conservative fallback rather than an exception that abandons a live decision.

Growth-stage-mapping service pipeline Left-to-right pipeline of three transform stages feeding a confirmed-stage output. Stage 1 (ingest and normalize) turns IoT, drone NDVI, phenocam and scout-log telemetry into a UTC daily-cadence series. Stage 2 (candidate mapping) blends NDVI, canopy height and accumulated GDD into a candidate BBCH stage and confidence score. Stage 3 (hysteresis machine) applies a sustained-days hold and a reversion window to confirm a monotonic transition. Inbound signals: Equipment Telemetry Parsing supplies field-zoned telemetry to stage 1; Weather API Integration supplies accumulated GDD to stage 2. The output is a confirmed BBCH stage with confidence and a metadata block that gates the timing consumers Weather Window Logic, Buffer Zone Calculations and dispatch. Each stage also emits a dashed branch into an append-only, hash-anchored audit ledger keyed by one correlation_id per stage. Equipment Telemetry Parsing field-zoned telemetry Weather API Integration accumulated GDD 1 · Ingest & normalize IoT · NDVI · phenocam · scout quarantine + bridge gaps → UTC daily series 2 · Candidate mapping NDVI × canopy × GDD signal agreement vote → candidate + confidence 3 · Hysteresis machine sustained-days hold reversion window · monotonic → confirmed transition Confirmed BBCH stage code · confidence · metadata → timing consumers Append-only audit ledger hash-anchored · one correlation_id per stage

The inputs are a per-field telemetry DataFrame plus a planting record and accumulated GDD; the output is a confirmed BBCH stage, a confidence score, and a metadata block. The integration points are strictly one-directional: this service consumes normalized telemetry and heat units and produces a stage signal that Weather Window Logic, 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 field telemetry

Field telemetry arrives from IoT soil probes, drone multispectral feeds, phenocams, and manual scout logs, each with its own timestamp convention and cadence. Standardize it into one validated series: convert every timestamp to UTC, resample to a consistent daily cadence, and interpolate short gaps only within an agronomic tolerance window so a multi-day sensor outage cannot be silently smoothed over. Reject physically impossible readings at the boundary and route them to a quarantine queue rather than letting them corrupt the phenological model.

Parameter Type Default Purpose
raw_readings list[dict] Heterogeneous device events with timestamp, ndvi, canopy_height, thermal_index.
interp_limit_days int 2 Max consecutive days a forward-fill may bridge before a gap is flagged.
quarantine_sink callable or None None Receives rejected malformed payloads; None logs and drops.
python
import logging
import pandas as pd
from typing import Any, Callable, Optional

logger = logging.getLogger("growth_stage.ingestion")
logger.setLevel(logging.INFO)


def ingest_and_normalize(
    raw_readings: list[dict[str, Any]],
    interp_limit_days: int = 2,
    quarantine_sink: Optional[Callable[[dict[str, Any]], None]] = None,
) -> pd.DataFrame:
    """Normalize heterogeneous telemetry to a UTC daily-cadence series.

    Malformed payloads are quarantined, not dropped silently; short gaps are
    forward-filled only within interp_limit_days of agronomic tolerance.
    """
    clean: list[dict[str, Any]] = []
    for row in raw_readings:
        ndvi = row.get("ndvi")
        if ndvi is None or not (0.0 <= ndvi <= 1.0):
            logger.warning("Quarantined reading | reason=ndvi_out_of_bounds ndvi=%s", ndvi)
            if quarantine_sink:
                quarantine_sink(row)
            continue
        clean.append(row)

    if not clean:
        raise ValueError("No valid telemetry after quarantine filter")

    df = pd.DataFrame(clean)
    df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
    df = df.set_index("timestamp").sort_index()

    # Resample to a consistent daily cadence; bridge only short gaps.
    numeric = df[["ndvi", "canopy_height", "thermal_index"]]
    daily = numeric.resample("1D").mean()
    gap_mask = daily["ndvi"].isna()
    daily = daily.ffill(limit=interp_limit_days)

    still_missing = int(daily["ndvi"].isna().sum())
    if still_missing:
        logger.warning(
            "Unbridged telemetry gap | missing_days=%d limit=%d",
            still_missing, interp_limit_days,
        )
    logger.info(
        "Telemetry normalized | days=%d interpolated=%d",
        len(daily), int(gap_mask.sum()) - still_missing,
    )
    return daily

Expected log output when a stray out-of-range NDVI and a short gap are handled:

text
WARNING growth_stage.ingestion Quarantined reading | reason=ndvi_out_of_bounds ndvi=1.42
INFO growth_stage.ingestion Telemetry normalized | days=30 interpolated=2

Step 2 — Map continuous metrics onto candidate BBCH codes

The BBCH scale is a discrete, ordered framework, but sensor outputs are continuous. Map a rolling window of NDVI, canopy height, and accumulated GDD onto a candidate stage and a confidence score rather than committing to a transition here — commitment is the state machine’s job in Step 3. Confidence is a weighted blend of how many independent signals agree, which lets a single noisy channel be outvoted instead of driving a spurious transition.

Parameter Type Default Effect on behavior
window pd.DataFrame Trailing normalized telemetry (typically 5-7 days).
accumulated_gdd float Growing-degree-days since emergence, from the weather feed.
stage_bands list[StageBand] Ordered BBCH bands with metric floors, from the versioned threshold set.
min_confidence float 0.70 Below this the candidate is treated as UNCERTAIN, not promoted.
python
from dataclasses import dataclass


@dataclass(frozen=True)
class StageBand:
    bbch_code: int
    name: str
    min_ndvi: float
    min_canopy_m: float
    min_gdd: float


@dataclass(frozen=True)
class StageCandidate:
    bbch_code: int
    name: str
    confidence: float


def map_candidate_stage(
    window: pd.DataFrame,
    accumulated_gdd: float,
    stage_bands: list[StageBand],
    min_confidence: float = 0.70,
) -> StageCandidate:
    """Blend NDVI, canopy, and GDD agreement into a candidate BBCH stage."""
    avg_ndvi = float(window["ndvi"].mean())
    avg_canopy = float(window["canopy_height"].mean())

    best = StageCandidate(0, "UNCERTAIN", 0.0)
    # Bands are ordered ascending; the highest satisfied band wins.
    for band in sorted(stage_bands, key=lambda b: b.bbch_code):
        signals = (
            avg_ndvi >= band.min_ndvi,
            avg_canopy >= band.min_canopy_m,
            accumulated_gdd >= band.min_gdd,
        )
        confidence = sum(signals) / len(signals)
        if all(signals) or (confidence > best.confidence and confidence >= min_confidence):
            best = StageCandidate(band.bbch_code, band.name, confidence)

    logger.info(
        "Candidate stage | bbch=%d name=%s confidence=%.2f ndvi=%.3f gdd=%.0f",
        best.bbch_code, best.name, best.confidence, avg_ndvi, accumulated_gdd,
    )
    return best

Expected log output when NDVI and GDD agree but canopy lags:

text
INFO growth_stage.ingestion Candidate stage | bbch=31 name=STEM_ELONGATION confidence=0.67 ndvi=0.612 gdd=612

Step 3 — Confirm the transition with a hysteresis state machine

A candidate is not a confirmed stage. To prevent alert flapping across management zones — a real effect of drainage and seeding-depth variance — the machine requires a candidate to persist for a sustained hold period before promotion, and it allows a short reversion window in which a confidence collapse rolls the state back rather than advancing. Stages are monotonic under normal growth: the machine never skips a band silently and logs every transition, confidence trajectory, and reversion for post-hoc analysis.

Parameter Type Default Effect on behavior
sustained_days int 2 Days a candidate must hold before the stage is promoted.
reversion_confidence float 0.70 Below this within the reversion window, the machine rolls back.
reversion_window_days int 1 How long after promotion a rollback is still permitted.
python
from datetime import datetime, timezone


class GrowthStageMachine:
    def __init__(self, sustained_days: int = 2, reversion_confidence: float = 0.70):
        self.sustained_days = sustained_days
        self.reversion_confidence = reversion_confidence
        self.current = StageCandidate(0, "PRE_EMERGENCE", 1.0)
        self._pending: Optional[StageCandidate] = None
        self._pending_days = 0

    def observe(self, candidate: StageCandidate) -> StageCandidate:
        """Advance, hold, or revert based on a fresh daily candidate."""
        # Reversion: confidence in the current stage has collapsed.
        if candidate.bbch_code == self.current.bbch_code and \
                candidate.confidence < self.reversion_confidence:
            logger.warning(
                "Stage confidence collapse | bbch=%d confidence=%.2f",
                self.current.bbch_code, candidate.confidence,
            )
            return self.current

        # Never skip a band; only consider the next stage forward.
        if candidate.bbch_code <= self.current.bbch_code:
            self._pending, self._pending_days = None, 0
            return self.current

        # Accumulate sustained-hold days for the pending candidate.
        if self._pending and self._pending.bbch_code == candidate.bbch_code:
            self._pending_days += 1
        else:
            self._pending, self._pending_days = candidate, 1

        if self._pending_days >= self.sustained_days:
            self.current = self._pending
            self._pending, self._pending_days = None, 0
            logger.info(
                "Stage transition confirmed | bbch=%d name=%s ts=%s",
                self.current.bbch_code, self.current.name,
                datetime.now(timezone.utc).isoformat(),
            )
        else:
            logger.info(
                "Stage transition pending | bbch=%d day=%d/%d",
                candidate.bbch_code, self._pending_days, self.sustained_days,
            )
        return self.current

Expected log output across a two-day confirmation:

text
INFO growth_stage.ingestion Stage transition pending | bbch=31 day=1/2
INFO growth_stage.ingestion Stage transition confirmed | bbch=31 name=STEM_ELONGATION ts=2026-05-14T00:00:00+00:00

The sustained_days, reversion_confidence, and every band floor are not literals to edit inline — they are surfaced through Threshold Tuning so an agronomist can recalibrate stage sensitivity from post-season feedback without touching the dispatch path.

Edge cases and known failure modes

Condition Symptom Fix
Sensor drift on a single NDVI channel Confidence inflates or deflates against reality; premature or stuck transitions Blend three independent signals (Step 2) so one drifting channel is outvoted; alert if a channel diverges from the median.
Multi-day satellite or drone outage Stage freezes on stale data or interpolation masks the gap Bridge only within interp_limit_days (Step 1); emit Unbridged telemetry gap and hold the stage rather than guessing.
Misaligned planting / emergence date GDD accumulation offset shifts every stage estimate Reconcile seed logs against emergence heat units before ingestion; a whole-season stage offset is the signature.
Zone-to-zone asynchrony (drainage, seeding depth) Alert flapping between adjacent BBCH codes Hysteresis hold and reversion window (Step 3) suppress single-day noise; widen sustained_days for high-variance fields.
Weather feed timeout starves GDD Candidate mapping loses a signal; confidence drops Missing GDD lowers confidence below min_confidence, so the machine holds instead of promoting on partial evidence.
Duplicate daily reading replayed on reconnect Two candidates for one day double-count the hold Resample by calendar day (Step 1) so a replay reconciles to one daily mean before the machine sees it.
Backward candidate from a canopy dip after lodging Machine appears to “regress” a stage Transitions are monotonic (Step 3 ignores bbch_code <= current); a genuine regression requires an explicit operator override.

Compliance and audit integration

Every confirmed stage is a compliance artifact, because it is the fact that licenses or blocks a subsequent application. 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 confirmed BBCH code and its confidence, the exact metric window and accumulated GDD that produced it, and the threshold_set_version the bands came from. Because the band floors originate in the EPA/USDA Rule Mapping threshold set and are stamped onto the record, retuning a threshold never rewrites history — a stage confirmed last season replays against the bands 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 confirmed stage is never edited in place, only superseded by a later confirmation with its own correlation_id. Because chemical efficacy and residue windows are stage-governed under the product label and FIFRA, an auditor can trace any cleared application back to the exact stage 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 phenology replay. Feed a full known-good season of daily telemetry with a fixed threshold set and assert the confirmed-stage timeline matches the expected BBCH progression day-for-day. A band-floor or GDD regression fails this immediately.
  2. Inject an out-of-range reading. Push an NDVI of 1.42 into Step 1 and assert it is quarantined, a ndvi_out_of_bounds warning is logged, and the confirmed stage is unaffected.
  3. Force a telemetry gap. Blank three consecutive days of NDVI and assert an Unbridged telemetry gap warning fires and the stage holds rather than advancing on stale interpolation.
  4. Trigger flapping. Alternate candidates between two adjacent BBCH codes on a one-day period and assert the hysteresis hold suppresses promotion until the candidate persists sustained_days, with one traceable ledger entry per confirmation.

A run passes only when each injected fault produces the expected degraded-but-safe result — a hold or a quarantine, never a spurious transition — and the ledger contains one traceable entry per stage. Deeper BBCH transition and alert-routing debugging cases are covered in Mapping BBCH growth stages to automated alerts.

Frequently Asked Questions

Why gate on BBCH stage instead of calendar date? Chemical efficacy and phytotoxicity are driven by the crop’s physiological stage, not the date. Weather shifts development off the calendar every season, so comparing an observed BBCH reading against the product label’s stage window keeps an application both effective and label-compliant even when the crop runs ahead of or behind the almanac.

Why require a sustained hold before confirming a transition? Adjacent management zones develop asynchronously because of drainage and seeding-depth variance, so a single day’s candidate can oscillate between two BBCH codes on noise alone. Requiring the candidate to persist for sustained_days and permitting a short reversion window suppresses that flapping, so alerts fire once on a real transition rather than repeatedly on jitter.

What happens when a telemetry channel or the weather feed drops out? Short gaps are bridged only within an agronomic tolerance window; anything longer is flagged and the stage holds on its last confirmed value. A missing GDD or NDVI signal lowers the candidate confidence below the promotion threshold, so the machine defers rather than advancing on partial evidence — a missing signal can only delay a transition, never force one.

How does the mapping stay reproducible after thresholds are retuned? Every confirmed stage stores the metric window, accumulated GDD, confidence, and the threshold_set_version its bands came from. Threshold sets are append-only and versioned, so replaying an old evaluation reconstructs the exact stage that cleared at the time even after the band floors have been recalibrated.

Up: Crop Application Timing & Agronomic Validation