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.

Earliest-deadline-first equipment sequencing flow Field jobs (window and area) and machines (availability and per-day capacity) feed an earliest-deadline-first sort. Each job is scanned across days and machines against four guards — inside window, matching class, machine available, capacity free — and either becomes a field-to-machine-day assignment or, when no feasible day exists inside its window, an unscheduled record. Inputs Field jobs window · area_ha Machines availability · ha/day Earliest-deadline first sort by due_by, area Scan day × machine 1 · inside window 2 · matching class 3 · machine available 4 · capacity free Assignment field · machine · day Unscheduled no feasible day in window

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.

python
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:

json
{"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):

json
{"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:

  1. 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.
  2. Capacity still binds. The extended day still respects ha_per_day; an override adds days, never hectares beyond a machine’s real throughput.
  3. Deadline still binds. The job must still land on or before its due_by; an override cannot place an operation past its agronomic window.
  4. 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.
python
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_class for the job, or their availability excludes the job’s window. Remediation: confirm the job’s op_class matches an available machine and check the machine’s available_from/available_to against the window; idle capacity of the wrong class cannot help.
  • A large field never places. Root cause: area_ha exceeds every machine’s ha_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 inflate ha_per_day, which overbooks the machine.
  • Many slack_days: 0 assignments. 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_by input, 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.

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