Sequencing Field Operations Under Equipment Constraints
Problem statement
A planting window tells you when a field may be worked; it does not tell you how a fleet of three planters and two sprayers covers forty fields before those windows close. That second problem is a resource-constrained scheduling problem, and it is where a plausible-looking plan quietly becomes infeasible. Two fields whose windows overlap both demand the only 16-row planter on the same day; a sprayer is down for service the exact three days a field is trafficable; the crew can staff two machines but not four. Assign fields to machines without honoring machine availability, per-day field capacity, and window deadlines together and you produce a schedule that cannot be executed — a machine is double-booked, or a field is planned for a day past its deadline.
This guide sequences operations across machines and days so that no machine is asked to cover more than its field-day capacity, no operation is placed outside its window or a machine’s availability, and the fields most at risk of missing their deadline are placed first. It takes the per-field windows derived in Scheduling Planting Windows with Growing Degree Days as fixed inputs and produces the concrete machine-day assignments that the planting schedule optimizer presents as a daily work order. The reference implementation is an earliest-deadline-first greedy heuristic — deterministic and explainable — and the closing section shows the same problem expressed as an OR-tools CP-SAT model for when the fleet is large enough that greedy leaves capacity idle.
Parameter reference table
Every value below changes whether an operation is placed or left unscheduled. Recommended values assume a mixed planting/spraying fleet over a three-week spring horizon.
| Parameter | Type | Recommended value | Effect on behavior |
|---|---|---|---|
ha_per_day |
float |
machine spec | Field-day capacity of a machine class in hectares. Set it above real throughput and the schedule overbooks the machine; below and fields deferred unnecessarily. |
available_from / available_to |
date |
service calendar | The days a machine can run. A service window inside the season must be reflected here or the plan books a machine that is down. |
op_class |
str |
"plant" / "spray" |
Matches a job to the machines that can perform it. A mismatch leaves a job unschedulable even with idle machines of the wrong class. |
ready_from / due_by |
date |
GDD window | The job’s window; it may only be placed on a day inside these bounds. due_by is the deadline the ordering protects. |
area_ha |
float |
field area | Hectares the job consumes from a machine-day. A job larger than any single ha_per_day can never fit and must be split upstream. |
horizon_days |
int |
21 |
Days searched forward for a feasible slot. Too short and late-window fields never place; too long wastes compute on impossible days. |
Hold machine availability and capacity in a version-stamped fleet registry rather than inline literals, so a mid-season service change re-sequences every affected job identically.
Runnable implementation
The module below sequences jobs across machines and days with an earliest-deadline-first heuristic. It targets Python 3.10+ (uses match/case and X | None unions), is fully typed, and depends only on the standard library — the greedy core needs no solver. It honors three constraints at once: a machine only runs inside its availability, a job only lands inside its window, and no machine-day is filled past its ha_per_day.
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from datetime import date, timedelta
logger = logging.getLogger("planting.sequencing")
@dataclass(frozen=True)
class Machine:
"""One machine of a given operation class with a daily field capacity."""
machine_id: str
op_class: str # "plant" or "spray"; matched against a job's op_class
ha_per_day: float # field-day capacity in hectares
available_from: date # first day the machine can run (service calendar)
available_to: date # last day the machine can run
@dataclass(frozen=True)
class FieldJob:
"""One field operation bounded by its GDD-derived window."""
field_id: str
op_class: str
area_ha: float
ready_from: date # window opens (earliest workable day)
due_by: date # window closes (hard deadline)
@dataclass(frozen=True)
class Assignment:
field_id: str
machine_id: str
day: date
area_ha: float
def sequence_operations(
jobs: list[FieldJob],
machines: list[Machine],
start: date,
horizon_days: int = 21,
) -> tuple[list[Assignment], list[FieldJob]]:
"""Earliest-deadline-first assignment honoring availability, capacity, and windows."""
# Remaining capacity per (machine_id, day); filled lazily from ha_per_day.
remaining: dict[tuple[str, date], float] = {}
capacity_of = {m.machine_id: m.ha_per_day for m in machines}
assignments: list[Assignment] = []
scheduled_ids: set[str] = set()
# Tightest deadline first; larger fields before smaller on a tie so big jobs
# claim capacity while it exists rather than being stranded at the end.
queue = sorted(jobs, key=lambda j: (j.due_by, -j.area_ha))
for job in queue:
placed = False
for offset in range(horizon_days):
day = start + timedelta(days=offset)
if not (job.ready_from <= day <= job.due_by):
continue # outside this job's window
for m in machines:
if m.op_class != job.op_class:
continue # wrong machine class for this operation
if not (m.available_from <= day <= m.available_to):
continue # machine is down / not yet available this day
key = (m.machine_id, day)
free = remaining.get(key, capacity_of[m.machine_id])
if free >= job.area_ha:
remaining[key] = free - job.area_ha
assignments.append(
Assignment(job.field_id, m.machine_id, day, job.area_ha))
scheduled_ids.add(job.field_id)
placed = True
logger.info(json.dumps(
{"event": "job_assigned", "field_id": job.field_id,
"machine_id": m.machine_id, "day": day.isoformat(),
"slack_days": (job.due_by - day).days}))
break
if placed:
break
if not placed:
logger.warning(json.dumps(
{"event": "job_unscheduled", "field_id": job.field_id,
"op_class": job.op_class, "due_by": job.due_by.isoformat(),
"reason": "no_capacity_in_window"}))
unscheduled = [j for j in jobs if j.field_id not in scheduled_ids]
return assignments, unscheduled
The three continue guards are the constraints made literal: window, machine class, and availability are each checked before capacity is even consulted, so an assignment can only be recorded when every constraint holds simultaneously. Sorting by due_by first is what makes the heuristic protect deadlines — a field with two days of slack is placed before one with a week, exactly when capacity is scarce. A job that finds no feasible machine-day inside its window is returned in the unscheduled list rather than force-fit past its deadline.
For fleets where greedy packing strands capacity, the identical problem maps to an OR-tools CP-SAT model: a Boolean x[job, machine, day] per feasible combination, AddExactlyOne (or Add(sum <= 1)) so each job is placed at most once, a per-(machine, day) linear constraint bounding summed area_ha by ha_per_day, and window/availability handled by only creating variables for feasible days. Maximizing the count of placed jobs — or a slack-weighted objective — then packs machine-days optimally. The greedy version above is the right default because it is deterministic and needs no solver dependency; reach for CP-SAT only when utilization, not explainability, is the binding concern.
Log patterns and observable signals
Every placement decision emits one JSON line so a finished sequence can be replayed field by field.
Normal assignment — the job landed inside its window with slack to spare:
{"event": "job_assigned", "field_id": "N-12", "machine_id": "planter_A", "day": "2026-04-21", "slack_days": 3}
Warning — no feasible machine-day existed inside the window (the shortfall this guide surfaces):
{"event": "job_unscheduled", "field_id": "S-07", "op_class": "plant", "due_by": "2026-04-24", "reason": "no_capacity_in_window"}
When triaging, filter on job_unscheduled first: each one is a field with no plan. If they concentrate on a single op_class, the fleet is short a machine of that class, not misconfigured — compare the deferred hectares against the total ha_per_day available for the class before adding a custom operator. A run with many slack_days: 0 assignments is feasible but fragile; a single rain day will push those fields past their deadline, so treat it as a signal to add capacity rather than a success.
Safe override protocol
Occasionally an operation must be sequenced onto a machine outside its recorded availability — a service appointment moved up, or an operator willing to run a night shift. The override must never bypass the window or capacity checks; it only widens one machine’s availability for a named span.
Guard conditions, all mandatory:
- Explicit machine and span, never a blanket waiver. The operator names the machine and the exact extra days; availability is widened only for that machine, not the whole fleet.
- Capacity still binds. The extended day still respects
ha_per_day; an override adds days, never hectares beyond a machine’s real throughput. - Deadline still binds. The job must still land on or before its
due_by; an override cannot place an operation past its agronomic window. - Audit trail. The machine id, the extended span, and the approver are written to an append-only ledger and flagged for review, consistent with the recordkeeping any downstream chemical application must satisfy under EPA pesticide application recordkeeping.
from dataclasses import replace
from datetime import date
def extend_availability(
machine: Machine,
new_to: date,
approver: str,
) -> Machine:
"""Widen one machine's availability window; capacity and deadlines still apply."""
if new_to <= machine.available_to:
raise ValueError("override must extend, not shorten, availability")
logger.warning(json.dumps(
{"event": "availability_override", "machine_id": machine.machine_id,
"old_to": machine.available_to.isoformat(), "new_to": new_to.isoformat(),
"approver": approver}))
return replace(machine, available_to=new_to) # frozen dataclass: return a copy
Because sequence_operations re-runs against the widened machine, the same window and capacity guards still gate every placement — the override changes only which days a single machine may run, never the agronomic or capacity limits.
Troubleshooting
- A job is unscheduled while machines sit idle. Root cause: the idle machines are the wrong
op_classfor the job, or their availability excludes the job’s window. Remediation: confirm the job’sop_classmatches an available machine and check the machine’savailable_from/available_toagainst the window; idle capacity of the wrong class cannot help. - A large field never places. Root cause:
area_haexceeds every machine’sha_per_day, so it can never fit one machine-day. Remediation: split the field into multi-day passes upstream before sequencing, or assign a higher-capacity machine class — do not inflateha_per_day, which overbooks the machine. - Many
slack_days: 0assignments. Root cause: demand nearly equals capacity inside a tight window, so every placement is on its deadline. Remediation: add a machine or extend availability through the override protocol; a single lost field-day will otherwise cascade into missed deadlines. - Fields place in a different order than expected. Root cause: the earliest-deadline-first sort ranked a tighter-window field ahead of an earlier-ready one. Remediation: this is intended — deadline, not readiness, governs order; if a specific field must lead, tighten its
due_byinput, do not reorder the queue by hand. - An override still leaves a job unscheduled. Root cause: widening availability did not help because the binding constraint was capacity or the deadline, not the machine’s days. Remediation: check whether the deferred hectares exceed the class’s total capacity inside the window, which calls for another machine rather than a longer availability span.
Frequently asked questions
When should I use CP-SAT instead of the greedy heuristic? Use greedy by default — it is deterministic and needs no solver. Move to an OR-tools CP-SAT model only when the fleet is large enough that greedy strands capacity and utilization outweighs explainability; both use the same inputs.
What happens to a field the sequencer cannot place? It is returned in the unscheduled list with a job_unscheduled entry naming the reason, never force-fit past its deadline. Clustering on one op_class means the fleet is short that machine class.
How does the sequencer honor a machine that is down for service? A service window is modeled as reduced available_from/available_to, and the sequencer skips those days before checking capacity, so the plan never books a down machine.
Related
- Scheduling Planting Windows with Growing Degree Days — derives the per-field windows this sequencer treats as fixed deadlines.
- Planting Schedule Optimization — presents these machine-day assignments as the prioritized daily work order.
Up: Planting Schedule Optimization · Season Planning & Crop Rotation Optimization