Planting Schedule Optimization
Planting schedule optimization is the sequencing layer of season planning: it turns a list of fields — each with its own agronomically valid planting window, soil-readiness state, and required equipment — into a concrete, prioritized, day-by-day work order that a finite fleet and crew can actually execute. This subsystem sits inside Season Planning & Crop Rotation Optimization and answers the operational question that a rotation plan alone never does: given that every field wants to be planted at once and the fleet cannot be everywhere, which field gets which machine on which day so that no window is missed? Get it wrong and the failure is not abstract — a corn field planted a week past its growing-degree-day (GDD) window loses yield potential that no in-season input can recover.
Problem framing: everything is ready at once, and the fleet is not
Spring compresses an entire season’s field operations into a few workable weeks, and the constraints that govern them do not compose cleanly. A planting window is opened by heat accumulation, so it is derived from a GDD model rather than a fixed calendar date — the per-field window is computed in Scheduling Planting Windows with Growing Degree Days. A field is only workable when its soil moisture has fallen below the trafficability threshold, a gate that shifts with every rain event. Each operation demands a specific machine class, and there are far fewer planters and sprayers than fields. Labor hours cap the total area any single day can cover regardless of how many machines are idle. And weather risk — a forecast storm three days out — makes some fields urgent that a naive earliest-deadline rule would leave for later.
The specific sub-problem this subsystem owns is producing a feasible schedule under all of those limits simultaneously, and doing it deterministically so the plan can be defended and reproduced. A schedule that ignores machine capacity is a wish list; one that ignores the soil-readiness gate sends a planter into a field it will rut and compact; one that ignores window closure quietly plans a field for a day after its yield is already forfeit. The scheduler’s job is to rank fields by genuine urgency, pack them against real capacity, and — critically — surface the fields it cannot fit rather than silently dropping them, so a human can add a custom operator or accept the loss with eyes open. The mechanics of assigning specific machines across days under those capacity limits are detailed in Sequencing Field Operations Under Equipment Constraints.
Two properties separate a schedule that survives contact with the field from one that looks tidy on paper. The first is that the plan is a snapshot, not a commitment: soil readiness and weather risk both move daily, so the scheduler is designed to be re-run every morning with fresh signals, and each run is cheap and deterministic enough that yesterday’s plan is discarded rather than patched. The second is that infeasibility is a first-class output. When demand exceeds the fleet — the normal state of a compressed spring — the interesting information is precisely which fields lost the contest and why, because that is what a manager acts on. A scheduler that hides the shortfall behind an optimistic average denies the operator the one decision that matters: whether to hire capacity, shift labor, or let a marginal field slip.
Prerequisites and dependencies
The scheduler consumes derived signals rather than raw data, so three upstream contracts must be in place before it runs. Confirm each one before wiring it into the planning loop.
- A per-field planting window. Each field’s
window_openandwindow_closedates come from GDD accumulation against a per-crop base and cap temperature, not a fixed calendar. That derivation — including the temperature clamp method and how a field is ranked by window opening — is owned by Scheduling Planting Windows with Growing Degree Days. The same GDD logic underpins the broader Growth Stage Mapping subsystem used later in the season. - A soil-moisture readiness gate and weather risk score. A field is eligible only when its sensors report soil below the trafficability threshold, and its urgency is amplified by near-term precipitation risk. The forecast-window scoring reuses the logic in Weather Window Logic, which turns a raw forecast into a per-day workability signal.
- A rotation-valid crop assignment. Which crop a field is planting this season — and therefore its GDD window and equipment class — must already satisfy the agronomic and herbicide-carryover rules encoded by Rotation Constraint Modeling. The scheduler sequences an assignment; it never invents one.
Library versions are pinned for reproducibility across planning runs: pydantic>=2.5 for typed field-operation models with validation, python>=3.10 for the match/case and X | None syntax used below, and structlog-style JSON logging so every scheduling decision is machine-auditable. For a solver-backed variant, ortools>=9.8 (CP-SAT) replaces the greedy core without changing the interface. Agronomic window guidance follows the USDA NRCS planting-date and soil-trafficability recommendations, and any chemical operation the schedule sequences must respect EPA pesticide application recordkeeping requirements.
Architecture of this subsystem
The scheduler is a three-stage transform over a fixed planning horizon: it gathers the day’s eligible operations (window-open, soil-ready), ranks them by urgency (slack days first, then weather risk), and packs them against per-machine and per-labor capacity — committing what fits and carrying the rest forward. Anything still unplaced when its window closes is emitted to a deferred queue, never dropped, and every decision writes a structured line to an append-only planning ledger so the finished schedule is fully explainable.
The inputs are a list of typed FieldOperation records plus a DayCapacity describing the fleet and crew; the outputs are an ordered list of ScheduledOp assignments and a list of deferred operations. The integration is one-directional: the scheduler consumes derived windows, readiness, and capacity and produces a schedule — it never mutates the GDD model or the rotation plan upstream of it.
Step-by-step implementation
The reference scheduler below is a single greedy, capacity-aware pass over a fixed horizon. It is deterministic, fully typed, runs on Python 3.10+, and passes a syntax and indentation check as shown. A CP-SAT variant is noted after the code.
Step 1 — Model each operation with its window, readiness, and machine
Every field operation carries the two dates that bound its GDD window, the machine class it requires, its soil-readiness flag, and a weather-risk score. A field_validator rejects any operation whose window closes before it opens, so a malformed window can never enter the ranking.
| Parameter | Type | Default | Purpose |
|---|---|---|---|
field_id |
str |
— | Stable identifier resolved against the field registry. |
area_ha |
float |
— | Field area in hectares; drives capacity consumption. Must be > 0. |
window_open / window_close |
date |
— | GDD-derived planting bounds; an operation is eligible only inside them. |
machine |
str |
— | Required equipment class (e.g. planter_16row); matched against capacity. |
soil_ready |
bool |
False |
Trafficability gate; a field is skipped on days it is not ready. |
weather_risk |
float |
0.0 |
Near-term risk in [0, 1]; breaks urgency ties toward at-risk fields. |
Step 2 — Rank eligible operations by genuine urgency
For each day, gather the operations whose window is open and whose soil is ready, then sort them by slack — days remaining until the window closes — with weather risk as the tiebreaker. A field with two days of slack under a storm forecast outranks one with a week of slack and clear skies, which is exactly the prioritization a fixed calendar order misses.
| Parameter | Type | Default | Purpose |
|---|---|---|---|
start |
date |
— | First day of the planning horizon. |
horizon_days |
int |
21 |
Number of days the scheduler plans forward. |
capacity.machine_ha |
dict[str, float] |
— | Hectares each machine class can cover per day. |
capacity.labor_hours |
float |
— | Crew hours available per day (a fleet-wide ceiling). |
Step 3 — Pack against capacity, then defer what cannot fit
Walk the ranked list and commit each operation whose whole area fits the day’s remaining machine and labor capacity, decrementing both. An operation that will not fit today stays pending and is retried on the next day it is eligible. Anything still pending after the horizon — because its window closed first — is emitted to the deferred queue with a logged reason, never silently dropped.
from __future__ import annotations
import logging
import uuid
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from pydantic import BaseModel, Field, ValidationInfo, field_validator
logger = logging.getLogger("planting.scheduler")
class FieldOperation(BaseModel):
"""One plantable field operation bounded by its GDD window."""
field_id: str
crop: str
area_ha: float = Field(gt=0)
window_open: date # earliest agronomically valid day (GDD-derived)
window_close: date # last day before the window is forfeit
machine: str # required equipment class, e.g. "planter_16row"
soil_ready: bool = False # trafficability gate from field moisture sensors
weather_risk: float = Field(ge=0.0, le=1.0, default=0.0)
@field_validator("window_close")
@classmethod
def _close_after_open(cls, v: date, info: ValidationInfo) -> date:
opened = info.data.get("window_open")
if opened is not None and v < opened:
raise ValueError("window_close precedes window_open")
return v
@dataclass(frozen=True)
class DayCapacity:
"""Per-day fleet and crew ceilings, held constant across the horizon."""
machine_ha: dict[str, float] # hectares per machine class per day
labor_hours: float # crew hours available per day
ha_per_labor_hour: float # throughput used to convert hours -> hectares
@dataclass(frozen=True)
class ScheduledOp:
field_id: str
machine: str
day: date
area_ha: float
def _urgency(op: FieldOperation, today: date) -> tuple[int, float]:
"""Sort key: fewer slack days first, then higher weather risk (negated)."""
slack = (op.window_close - today).days
return (slack, -op.weather_risk)
def build_schedule(
ops: Iterable[FieldOperation],
capacity: DayCapacity,
start: date,
horizon_days: int = 21,
) -> tuple[list[ScheduledOp], list[FieldOperation]]:
"""Greedy, capacity-aware planting schedule over a fixed horizon."""
run_id = str(uuid.uuid4())
logger.info(
'{"event":"schedule_run_start","run_id":"%s","started_at":"%s","horizon_days":%d}',
run_id, datetime.now(timezone.utc).isoformat(), horizon_days,
)
pending: list[FieldOperation] = list(ops)
scheduled: list[ScheduledOp] = []
for offset in range(horizon_days):
day = start + timedelta(days=offset)
# Eligible = window open on this day AND soil trafficable.
eligible = [
op for op in pending
if op.window_open <= day <= op.window_close and op.soil_ready
]
eligible.sort(key=lambda o: _urgency(o, day))
remaining_ha = dict(capacity.machine_ha) # copy per day
labor_ha = capacity.labor_hours * capacity.ha_per_labor_hour
for op in eligible:
machine_cap = remaining_ha.get(op.machine, 0.0)
# Do not split a field: only commit when the whole area fits both limits.
if op.area_ha > machine_cap or op.area_ha > labor_ha:
continue
remaining_ha[op.machine] = machine_cap - op.area_ha
labor_ha -= op.area_ha
scheduled.append(ScheduledOp(op.field_id, op.machine, day, op.area_ha))
pending.remove(op)
logger.info(
'{"event":"op_scheduled","run_id":"%s","field_id":"%s",'
'"machine":"%s","day":"%s","slack_days":%d}',
run_id, op.field_id, op.machine, day.isoformat(),
(op.window_close - day).days,
)
# Whatever never fit before its window closed is deferred, not discarded.
for op in pending:
logger.warning(
'{"event":"op_deferred","run_id":"%s","field_id":"%s",'
'"reason":"no_capacity_before_window_close","window_close":"%s"}',
run_id, op.field_id, op.window_close.isoformat(),
)
return scheduled, pending
Because the greedy pass exposes a single build_schedule signature, swapping in an OR-tools CP-SAT model — one Boolean assignment variable per (field, machine, day), capacity as a linear constraint per machine-day, and window bounds as domain restrictions — is a drop-in replacement when the fleet is large enough that greedy packing leaves capacity on the table. The equipment-sequencing guide works that solver formulation end to end.
Expected log output when capacity forces a deferral:
{"event": "schedule_run_start", "run_id": "b2c1…", "horizon_days": 21}
{"event": "op_scheduled", "run_id": "b2c1…", "field_id": "N-12", "machine": "planter_16row", "day": "2026-04-21", "slack_days": 2}
{"event": "op_deferred", "run_id": "b2c1…", "field_id": "S-07", "reason": "no_capacity_before_window_close", "window_close": "2026-04-24"}
Edge cases and known failure modes
| Condition | Symptom | Fix |
|---|---|---|
| Every field’s window opens the same week | Fleet capacity is exceeded and low-priority fields never place | Urgency ranking plants the tightest-window fields first; overflow deferred with a reason so an operator can add capacity. |
| Soil never becomes trafficable inside the window | Field is eligible on no day and silently drops out | Deferred-queue emission flags it explicitly; correlate with the moisture gate rather than assuming a scheduler bug. |
| A single field exceeds one machine’s daily hectares | Field can never fit and blocks its own placement | Detect area_ha > max machine_ha at load and split the operation into multi-day passes upstream, or assign a higher-capacity class. |
| Weather forecast flips a low-risk field to high-risk mid-run | A field that should jump the queue is planted late | Recompute weather_risk before each run from Weather Window Logic; the schedule is a plan, re-run daily. |
| Window computed from a wrong crop base temperature | Field is scheduled outside its true agronomic window | Windows are owned by the GDD module; validate window_close >= window_open here and reconcile crop base temps upstream. |
| Two machine classes could each do an operation | Greedy picks the first match, possibly the scarcer machine | Model the operation’s machine as the preferred class and let the CP-SAT variant choose across classes to balance load. |
| Labor ceiling binds before any machine does | Idle machines sit while the crew cap is hit | Surface labor_ha exhaustion in the ledger so the constraint is visible; add a shift rather than assuming machine shortage. |
Compliance and audit integration
A planting schedule is a planning artifact, but the operations it sequences become regulated records the moment a machine engages, so the ledger this subsystem writes is the first link in that chain. Three facts must survive from plan to inspection: which field was assigned which operation on which day, the urgency reason that ordered it, and — for any chemical operation — the compliance fields required by the rule set. Those fields are never invented by the scheduler; they originate in the versioned EPA/USDA rule mapping and are stamped onto the operation record so a schedule generated this season replays against the contract that was in force when it ran.
The immutability guarantee matters because a deferred field is itself a defensible decision. When a window closes without capacity, the op_deferred entry records why — not enough machine hours before the deadline — which is the evidence a farm manager needs to justify a yield loss or a custom-operator expense. Because each run carries its own run_id, a plan generated on one morning never overwrites the record of the plan it replaced; the two coexist in the ledger, so an auditor or an agronomist can reconstruct exactly what was known and decided on any given day rather than seeing only the final state. Planting-date and soil-trafficability guidance follows USDA NRCS conservation recommendations, and any sequenced application inherits the buffer and setback obligations enforced elsewhere in the platform. Structured logs should stream to a centralized observability platform so deferral rate, capacity utilization, and window-miss counts are tracked as season-health signals rather than discovered at harvest.
Verification
Confirm correct behavior in staging with a reproducible scenario before promoting a change:
- Feasible baseline. Feed fields whose total area fits the fleet comfortably and assert every operation lands inside its window, no deferrals occur, and the ledger contains one
op_scheduledentry per field. - Inject a capacity squeeze. Give three fields the same tight window and a fleet that can plant only two, then assert the two highest-urgency fields (fewest slack days) are scheduled and the third is deferred with
no_capacity_before_window_close— not dropped. - Inject a soil-readiness stall. Mark one field
soil_ready=Falsefor its entire window and assert it is deferred, confirming the trafficability gate, not a ranking bug, held it back. - Inject a weather-risk tie-break. Give two fields identical slack but different
weather_riskand assert the higher-risk field is scheduled first, proving the tiebreaker fires.
A run passes only when each injected constraint produces the expected prioritized-but-safe schedule and every deferral carries a traceable reason. Deeper machine-assignment and window-derivation cases are covered in the two guides below.
Frequently Asked Questions
Why greedy ranking instead of a full optimizer by default? A greedy earliest-window pass is deterministic, fast, and — crucially — explainable: every assignment traces to a slack-days number an agronomist can check. It is the right default when the binding constraint is time, not fleet packing efficiency. When the fleet is large and greedy leaves capacity idle, the same interface accepts an OR-tools CP-SAT model that packs machine-days optimally, which the equipment-sequencing guide develops.
What stops the scheduler from silently missing a field?
Nothing is ever dropped. An operation that cannot be placed before its window closes is emitted to a deferred queue with an op_deferred ledger entry naming the reason — capacity, readiness, or a closed window. The schedule surfaces the shortfall so a human decides whether to hire a custom operator or accept the loss, rather than discovering an unplanted field in June.
How do soil moisture and weather feed into the schedule? Soil moisture is a hard eligibility gate: a field is only a candidate on days its sensors report it trafficable. Weather risk is a soft priority signal that breaks urgency ties toward fields threatened by a near-term storm. Both are recomputed before each run, so the schedule is regenerated daily rather than treated as a fixed calendar.
Where do the planting windows themselves come from?
Each field’s window_open and window_close are derived from growing-degree-day accumulation against a per-crop base and cap temperature, not a fixed date. That computation, including the temperature-clamp method and field ranking, lives in the growing-degree-day scheduling guide; this subsystem consumes the windows and sequences work against them.
Related
- Scheduling Planting Windows with Growing Degree Days — derives the per-field window dates this scheduler ranks against.
- Sequencing Field Operations Under Equipment Constraints — the machine-assignment and CP-SAT layer behind the capacity packing.
- Rotation Constraint Modeling — supplies the rotation-valid crop assignment each scheduled operation plants.
- Weather Window Logic — turns a raw forecast into the weather-risk score that breaks urgency ties.
- Growth Stage Mapping — the in-season GDD staging that shares this subsystem’s heat-accumulation model.