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.
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.0–50.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.0–250.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.
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:
{"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):
{"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):
{"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:
- 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.
- Continuity gate. The backfilled series must have no remaining gaps and its length must match the horizon exactly; a mismatch raises rather than truncating.
- Plausibility gate. The derived
open_datemust fall within the crop’s regional planting envelope; a window opening in January is rejected and the field returns to review. - 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_cis 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 ontmaxis what bounds a hot day’s contribution. window_never_openedfor a crop that clearly grows locally. Root cause:base_temp_cis 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_dateis null for late-season fields. Root cause: the temperature horizon ended before cumulative GDD reachedplant_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:
Noneopen dates were sorted as if comparable to real dates. Remediation: userank_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.
Related
- Planting Schedule Optimization — consumes these window dates to build the resource-constrained daily schedule.
- Sequencing Field Operations Under Equipment Constraints — assigns machines across the windows this module derives.
- Growth Stage Mapping — applies the same heat-accumulation model to in-season phenological stages.
Up: Planting Schedule Optimization · Season Planning & Crop Rotation Optimization