Scheduling Planting Windows with Growing Degree Days

Problem statement

A planting window is not a calendar date; it is a temperature event. A crop germinates and establishes over a band of accumulated heat, and that band arrives weeks earlier in a warm spring than a cold one. Pin the window to a fixed date and a warm year plants into a window that has already closed while a cold year plants into soil that has not accumulated enough heat to establish the stand. The fix is to compute the window from growing-degree-day (GDD) accumulation — a running sum of daily heat above a crop-specific base temperature — and open each field’s window when that sum crosses a per-crop threshold.

The subtle failures are all in the arithmetic. Average two raw daily temperatures without clamping and a hot afternoon inflates the day’s GDD as if the crop banked heat it cannot use above its cap temperature; let a freezing night drag the average below the base and the day subtracts growth that never reverses. Both errors compound across a season into a window that opens on the wrong day. This guide computes GDD with the correct base/cap clamp, derives the open and close dates per crop, and ranks fields by window opening so the planting schedule optimizer can sequence the tightest-window fields first. The heat-accumulation model here is the same one that drives in-season growth stage mapping; this page applies it to the pre-plant window rather than to phenological stages.

Growing-degree-day planting-window derivation flow Daily min and max temperatures are clamped (min floored at the base temperature, max capped at the cap temperature), averaged, and the base subtracted with a zero floor to give daily GDD. Daily GDD is accumulated with a cumulative sum, then a crossing-detection stage opens the window at the start threshold and closes it at the end threshold, producing a ranked planting window or a null window when heat never accumulates. Daily temps tmin · tmax (°C) per day Clamp floor tmin at base cap tmax at cap Daily GDD avg − base zero floor Accumulate cumulative sum Detect crossings ≥ start → open ≥ end → close Planting window open / close date rank by opening None if no heat

Parameter reference table

Every value below changes which day a field’s window opens or closes. Recommended values assume a corn/soy Midwest baseline and daily min/max temperatures in degrees Celsius; adjust per crop from an extension service table.

Parameter Type Recommended value Effect on behavior
base_temp_c float 10.0 (corn) Temperature below which no heat accrues. Too low over-counts cold days and opens the window early; too high starves accumulation and opens it late.
cap_temp_c float 30.0 (corn) Upper clamp; heat above it does not add GDD. Omitting the cap lets a heat wave inflate accumulation and open the window prematurely.
plant_start_gdd float 0.050.0 Cumulative GDD at which the window opens. A small non-zero value delays planting until soil has begun to warm.
plant_end_gdd float 150.0250.0 Cumulative GDD at which the window closes; past it, the agronomic planting slot is lost.
tmin / tmax np.ndarray daily °C series The daily minimum and maximum temperatures; length sets the horizon. Gaps must be filled upstream, never zero-padded.
start date season day 0 Calendar anchor for day 0 of the temperature series; every window date is offset from it.

Store base_temp_c, cap_temp_c, and the two thresholds in a version-stamped per-crop registry rather than scattering literals through field code, so a single agronomic revision re-derives every field’s window identically.

Runnable implementation

The module below computes clamped daily GDD, accumulates it, derives each field’s open/close window, and ranks fields by window opening. It targets Python 3.10+ (uses match/case and X | None unions), is fully typed, and depends only on numpy. The clamp follows the modified average method described by USDA NRCS growing-degree guidance: floor the daily minimum at the base and cap the daily maximum before averaging.

python
from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from datetime import date, timedelta

import numpy as np

logger = logging.getLogger("planting.gdd")


@dataclass(frozen=True)
class CropGDD:
    """Per-crop heat-accumulation parameters, held in a version-stamped registry."""

    crop: str
    base_temp_c: float      # no growth accrues below this temperature
    cap_temp_c: float       # heat above this is clamped out of the daily average
    plant_start_gdd: float  # cumulative GDD at which the window opens
    plant_end_gdd: float    # cumulative GDD at which the window closes


@dataclass(frozen=True)
class PlantingWindow:
    field_id: str
    crop: str
    open_date: date | None    # None means the window never opened in the series
    close_date: date | None   # None means the horizon ended before it closed
    gdd_at_open: float


def daily_gdd(tmin: np.ndarray, tmax: np.ndarray, base: float, cap: float) -> np.ndarray:
    """Clamped modified-average GDD: floor tmin at base, cap tmax, then average."""
    if tmin.shape != tmax.shape:
        raise ValueError("tmin and tmax must be the same length")
    tmax_clamped = np.clip(tmax, None, cap)     # heat above cap adds nothing
    tmin_clamped = np.clip(tmin, base, cap)     # cold nights cannot subtract growth
    avg = (tmax_clamped + tmin_clamped) / 2.0
    return np.maximum(avg - base, 0.0)          # a below-base day contributes 0, never < 0


def _first_crossing(cumulative: np.ndarray, threshold: float) -> int | None:
    """Index of the first day cumulative GDD reaches threshold, or None."""
    hits = cumulative >= threshold
    return int(np.argmax(hits)) if bool(hits.any()) else None


def planting_window(
    field_id: str,
    spec: CropGDD,
    start: date,
    tmin: np.ndarray,
    tmax: np.ndarray,
) -> PlantingWindow:
    """Accumulate GDD and derive the field's open/close planting window."""
    gdd = daily_gdd(tmin, tmax, spec.base_temp_c, spec.cap_temp_c)
    cumulative = np.cumsum(gdd)

    open_idx = _first_crossing(cumulative, spec.plant_start_gdd)
    close_idx = _first_crossing(cumulative, spec.plant_end_gdd)

    match (open_idx, close_idx):
        case (None, _):
            # Not enough heat all season: no window, do not fabricate one.
            window = PlantingWindow(field_id, spec.crop, None, None, 0.0)
            logger.warning(json.dumps(
                {"event": "window_never_opened", "field_id": field_id,
                 "crop": spec.crop, "peak_gdd": round(float(cumulative[-1]), 1)}))
            return window
        case (o, None):
            open_date = start + timedelta(days=o)
            window = PlantingWindow(field_id, spec.crop, open_date, None,
                                    round(float(cumulative[o]), 1))
        case (o, c):
            open_date = start + timedelta(days=o)
            close_date = start + timedelta(days=c)
            window = PlantingWindow(field_id, spec.crop, open_date, close_date,
                                    round(float(cumulative[o]), 1))

    logger.info(json.dumps(
        {"event": "window_derived", "field_id": field_id, "crop": spec.crop,
         "open_date": window.open_date.isoformat() if window.open_date else None,
         "close_date": window.close_date.isoformat() if window.close_date else None,
         "gdd_at_open": window.gdd_at_open}))
    return window


def rank_fields_by_opening(windows: list[PlantingWindow]) -> list[PlantingWindow]:
    """Order fields by window opening; fields that never opened sort last."""
    def key(w: PlantingWindow) -> tuple[int, date]:
        # (1, far-future) pushes never-opened windows to the end deterministically.
        return (0, w.open_date) if w.open_date is not None else (1, date.max)
    return sorted(windows, key=key)

The clamp order is the load-bearing detail: tmin is floored at the base before averaging so a hard frost cannot pull the day negative, and tmax is capped so a heat spike above the crop’s ceiling does not bank phantom heat. Accumulating first and finding the crossing second means the window dates fall out of a single cumsum, and a season that never reaches plant_start_gdd yields None rather than a fabricated date.

Log patterns and observable signals

Every derivation emits a single JSON line so any window can be traced back to the crop spec and temperature series that produced it.

Normal derivation — the window opened and closed inside the horizon:

json
{"event": "window_derived", "field_id": "N-12", "crop": "corn", "open_date": "2026-04-19", "close_date": "2026-05-03", "gdd_at_open": 12.5}

Warning — heat accumulated but the horizon ended before the window closed (extend the temperature series):

json
{"event": "window_derived", "field_id": "S-07", "crop": "corn", "open_date": "2026-05-28", "close_date": null, "gdd_at_open": 11.0}

Warning — the season never reached the opening threshold (a cold site or a wrong base temperature):

json
{"event": "window_never_opened", "field_id": "W-03", "crop": "corn", "peak_gdd": 41.2}

When triaging, filter on window_never_opened first: it means a field entered no schedule at all. A run of them for one crop points at a base temperature set too high in the registry, not a genuinely cold season — cross-check the value against an extension table before touching the temperature feed.

Safe override protocol

Occasionally a window must be admitted despite an incomplete temperature series — a late-installed weather station, or a field whose feed dropped out for several days. The override must never fabricate temperatures inside daily_gdd; it only substitutes a vetted external series so the same clamp and accumulation still run.

Guard conditions, all mandatory:

  1. Named source, never a guess. The operator supplies a concrete backfill series from an identified station or reanalysis product; the module never interpolates a multi-day gap silently.
  2. Continuity gate. The backfilled series must have no remaining gaps and its length must match the horizon exactly; a mismatch raises rather than truncating.
  3. Plausibility gate. The derived open_date must fall within the crop’s regional planting envelope; a window opening in January is rejected and the field returns to review.
  4. Audit trail. The substituted station id, the gap span, and the operator identity are written to an append-only ledger, consistent with the recordkeeping expectations that govern any downstream chemical operation under EPA pesticide application recordkeeping.

Because plant_start_gdd and the base/cap temperatures are required fields on CropGDD with no silent defaults, an override can only change which temperatures feed the model, never the agronomic thresholds that define the window.

Troubleshooting

  • Window opens several days too early across every field. Root cause: cap_temp_c is unset or too high, so a heat wave inflated accumulation. Remediation: confirm the crop’s cap against an extension table and re-derive; the clamp on tmax is what bounds a hot day’s contribution.
  • window_never_opened for a crop that clearly grows locally. Root cause: base_temp_c is set too high, so ordinary spring days contribute zero GDD. Remediation: correct the base in the registry; do not lower it below the crop’s true threshold just to force a window, which shifts every downstream date.
  • Window dates jump when a temperature gap is zero-filled. Root cause: a missing day was padded with 0 °C, which floors to the base and stalls accumulation. Remediation: backfill the gap through the override protocol with a real station series; never treat a missing reading as zero.
  • close_date is null for late-season fields. Root cause: the temperature horizon ended before cumulative GDD reached plant_end_gdd. Remediation: extend the series to cover the full expected window; a null close is a horizon limit, not a modeling error.
  • Ranking places a never-opened field mid-list. Root cause: None open dates were sorted as if comparable to real dates. Remediation: use rank_fields_by_opening, which sorts never-opened windows deterministically last rather than raising on the comparison.

Frequently asked questions

Why clamp both the minimum and maximum before averaging? Flooring tmin at the base stops a frost from subtracting growth, and capping tmax stops a heat spike from banking heat the crop cannot use. Without both clamps the error compounds across the season into a window that opens on the wrong day.

What happens when a field never reaches the opening threshold? The window’s open_date is None and a window_never_opened line is logged with the peak GDD — no date is fabricated. A batch of these for one crop usually means base_temp_c is set too high, not a cold season.

How are fields ranked once windows are computed? By the calendar date the window opens, earliest first, so the planting schedule optimizer sequences the tightest-window fields first. Never-opened fields sort last rather than raising.

Up: Planting Schedule Optimization · Season Planning & Crop Rotation Optimization