Encoding Herbicide Carryover Restrictions as Constraints

Problem statement

Herbicide carryover is the quiet way a rotation plan turns into a failed stand. A residual herbicide applied to this year’s crop persists in the soil, and if next year’s crop is sensitive to it and the labeled plant-back interval has not fully elapsed, the replant emerges stunted, chlorotic, or not at all — and the loss is only visible weeks after the seed is in the ground. The rule that would have caught it is printed on the EPA-approved product label as a rotational-crop restriction, and it is legally binding. This guide encodes that restriction as a machine-readable constraint so a proposed next crop is checked against every prior application before the plan is committed, inside the broader rotation constraint modeling subsystem.

The check is more than “has enough time passed.” A plant-back interval is conditional: atrazine breaks down more slowly in high-pH soils and in a dry season, so its label extends the interval when soil pH exceeds a threshold or cumulative rainfall since application falls short. Encoding only the nominal interval passes a plan that a wet-year assumption would fail. The module below models three common residuals — atrazine, imazethapyr, and clopyralid — with their conditional extensions, and returns a typed replant verdict of SAFE, FLAG, or BLOCK rather than a bare boolean, so an agronomist can distinguish a hard label breach from a conditional window that needs review.

Parameter reference table

Every value below changes whether a replant risk is caught or missed. Recommended values follow common US label language; always confirm the exact interval against the specific product label for the rate applied.

Parameter Type Recommended value Effect on behavior
min_months int per label The labeled minimum plant-back interval; below it the verdict is always BLOCK, regardless of conditions.
ph_threshold Decimal 7.5 Soil pH above which residual breakdown slows and the interval extends (atrazine, clopyralid).
ph_extends_months int 6 Extra months added to the interval when the pH or rainfall condition is met.
min_rain_mm Decimal 150300 Cumulative rainfall the label assumes for breakdown; below it the extended interval applies.
rate_g_ai_ha Decimal applied rate Grams of active ingredient per hectare; a higher rate lengthens real persistence and should widen the interval.
plant_date date planned The proposed planting date of the next crop; elapsed months are measured to it, not to a calendar year.
next_crop str Canonical crop code; the restriction table is keyed on the active-ingredient/next-crop pair.

Keep the plant-back table in a version-stamped registry rather than inline literals, so a label revision re-checks every historical plan the same way — the same versioning discipline the parent rotation constraint modeling subsystem applies to its whole rule set.

Herbicide carryover replant-risk decision flow A prior application feeds an elapsed-months computation; a first decision flags anything below the labeled minimum interval as BLOCK, a second decision flags anything inside the pH or rainfall-extended window as FLAG, and everything else is SAFE. Each verdict appends to a log recording field, active ingredient, elapsed months, required months, and risk. no no yes yes Prior application AI · date · rate soil pH rainfall since Elapsed months plant − applied below labeled minimum? within extended pH / rain window? SAFE plant-back met BLOCK inside min interval FLAG agronomist review Append-only replant log field · AI · elapsed · required · risk · reason

Runnable implementation

The module below is fully typed, targets Python 3.10+, and depends only on the standard library. It measures elapsed months to the actual planting date, applies the conditional extension when soil pH or rainfall triggers it, and returns a typed verdict. Rates use Decimal so a regulated value never carries a binary-float rounding error.

python
from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from enum import Enum

logger = logging.getLogger("carryover.check")


class Risk(str, Enum):
    SAFE = "safe"
    FLAG = "flag"      # inside a pH/rainfall-extended window; needs agronomist review
    BLOCK = "block"    # inside the labeled minimum interval; not admissible


@dataclass(frozen=True)
class Application:
    field_id: str
    active_ingredient: str
    applied_on: date
    rate_g_ai_ha: Decimal
    soil_ph: Decimal
    rainfall_since_mm: Decimal


@dataclass(frozen=True)
class Restriction:
    min_months: int          # labeled minimum plant-back interval
    ph_extends_months: int   # extra months when the pH or rainfall condition holds
    ph_threshold: Decimal    # soil pH above which breakdown slows
    min_rain_mm: Decimal     # cumulative rainfall the label assumes for breakdown


# active ingredient -> next crop -> restriction (representative US label values)
PLANT_BACK: dict[str, dict[str, Restriction]] = {
    "atrazine": {
        "soybean": Restriction(12, 6, Decimal("7.5"), Decimal("150")),
        "sugarbeet": Restriction(24, 0, Decimal("7.5"), Decimal("300")),
    },
    "imazethapyr": {
        "corn": Restriction(9, 0, Decimal("7.8"), Decimal("0")),
        "sugarbeet": Restriction(40, 0, Decimal("7.8"), Decimal("0")),
    },
    "clopyralid": {
        "sugarbeet": Restriction(18, 0, Decimal("7.5"), Decimal("0")),
        "canola": Restriction(18, 0, Decimal("7.5"), Decimal("0")),
    },
}


@dataclass(frozen=True)
class ReplantVerdict:
    risk: Risk
    field_id: str
    active_ingredient: str
    next_crop: str
    months_elapsed: int
    required_months: int
    reason: str


def _months_between(start: date, end: date) -> int:
    return (end.year - start.year) * 12 + (end.month - start.month)


def assess_replant(app: Application, next_crop: str, plant_date: date) -> ReplantVerdict:
    """Return the replant risk of next_crop given one prior herbicide application."""
    restriction = PLANT_BACK.get(app.active_ingredient, {}).get(next_crop)
    elapsed = _months_between(app.applied_on, plant_date)

    if restriction is None:
        # No labeled restriction for this AI/crop pair: nothing to enforce here.
        return _finish(Risk.SAFE, app, next_crop, elapsed, 0,
                       "no rotational restriction on record for this pair")

    # High pH or a dry season slows residual breakdown, so the label extends the interval.
    conditional = (app.soil_ph > restriction.ph_threshold
                   or app.rainfall_since_mm < restriction.min_rain_mm)
    required = restriction.min_months + (restriction.ph_extends_months if conditional else 0)

    if elapsed < restriction.min_months:
        risk, reason = Risk.BLOCK, "inside labeled minimum plant-back interval"
    elif elapsed < required:
        risk, reason = Risk.FLAG, "inside pH/rainfall-extended interval; review required"
    else:
        risk, reason = Risk.SAFE, "plant-back interval satisfied"

    return _finish(risk, app, next_crop, elapsed, required, reason)


def _finish(risk: Risk, app: Application, next_crop: str, elapsed: int,
            required: int, reason: str) -> ReplantVerdict:
    verdict = ReplantVerdict(risk, app.field_id, app.active_ingredient,
                             next_crop, elapsed, required, reason)
    logger.info(json.dumps({
        "event": "replant_assessed",
        "field_id": verdict.field_id,
        "active_ingredient": verdict.active_ingredient,
        "next_crop": verdict.next_crop,
        "months_elapsed": verdict.months_elapsed,
        "required_months": verdict.required_months,
        "risk": verdict.risk.value,
        "reason": verdict.reason,
    }))
    return verdict


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(message)s")
    prior = Application(
        field_id="S-07",
        active_ingredient="atrazine",
        applied_on=date(2026, 5, 12),
        rate_g_ai_ha=Decimal("1120"),
        soil_ph=Decimal("7.8"),          # above 7.5 -> breakdown slows
        rainfall_since_mm=Decimal("120"),  # below 150 -> interval extends
    )
    result = assess_replant(prior, "soybean", date(2027, 5, 1))
    print(f"{result.risk.value}: elapsed={result.months_elapsed} required={result.required_months}")

In the example, twelve months have elapsed — enough to clear atrazine’s twelve-month minimum for soybean — but the high soil pH and low rainfall extend the interval to eighteen months, so the verdict is FLAG rather than SAFE. That is the whole point of encoding the condition: the nominal interval alone would have waved the plan through.

Log patterns and observable signals

Every assessment emits one structured line, so any planting decision can be traced back to the application and interval that judged it.

Safe path (interval fully satisfied):

json
{"event": "replant_assessed", "field_id": "S-07", "active_ingredient": "atrazine", "next_crop": "soybean", "months_elapsed": 24, "required_months": 18, "risk": "safe", "reason": "plant-back interval satisfied"}

Conditional flag (nominal interval met, extended interval not):

json
{"event": "replant_assessed", "field_id": "S-07", "active_ingredient": "atrazine", "next_crop": "soybean", "months_elapsed": 12, "required_months": 18, "risk": "flag", "reason": "inside pH/rainfall-extended interval; review required"}

Hard block (inside the labeled minimum):

json
{"event": "replant_assessed", "field_id": "N-03", "active_ingredient": "imazethapyr", "next_crop": "sugarbeet", "months_elapsed": 12, "required_months": 40, "risk": "block", "reason": "inside labeled minimum plant-back interval"}

When triaging a plan, filter on risk of block first — those are legal label breaches that must change the crop or the date. A run of flag results on one active ingredient usually points at a dry season or a high-pH block of fields; correlate the field_id set against soil-test records before overriding any of them.

Safe override protocol

Occasionally a FLAG verdict must be admitted — a field whose soil test post-dates the application shows a pH that no longer slows breakdown, or a localized rainfall gauge recorded more than the regional figure. The override supplies better evidence to the same check; it never bypasses assess_replant, and a BLOCK is never overridable because the labeled minimum is not advisory.

Guard conditions, all mandatory:

  1. Never override a BLOCK. Only a FLAG — a conditional extension — is eligible. A verdict inside the labeled minimum interval requires a different crop or a later date, full stop.
  2. Evidence, not judgment. The override must supply a concrete measurement (a dated soil-test pH, a field-level rainfall total) that re-runs the check, not a free-text waiver.
  3. Dry-run and re-assess. Re-run assess_replant with the corrected input; the override is valid only if the new verdict is SAFE.
  4. Immutable audit trail. The original verdict, the corrected input, the approver identity, and the citation to the governing pesticide label are written to an append-only ledger for compliance review.

Troubleshooting

  • A plan passes that a wet-year assumption should fail. Root cause: only min_months is encoded and the pH/rainfall extension is missing. Remediation: populate ph_extends_months, ph_threshold, and min_rain_mm from the label; a FLAG on a high-pH field is the intended behavior, not a bug.
  • Interval looks satisfied but the replant is stunted. Root cause: elapsed time was measured to a calendar year boundary rather than to the real planting date. Remediation: measure months to the actual plant_date; a May application to a May planting is twelve months, but to an April planting it is only eleven.
  • A high application rate carried over past the nominal interval. Root cause: the table encodes a single interval independent of rate. Remediation: key the restriction on a rate band, or widen the interval for the top label rate; carry rate_g_ai_ha into the decision rather than logging it only.
  • An unmodeled active ingredient returns SAFE. Root cause: the AI/crop pair is absent from PLANT_BACK, so no restriction applies. Remediation: treat an unknown residual as unmodeled, not clean — surface a FLAG from a catch-all entry until the label is encoded, the same way the parent rotation constraint modeling subsystem flags unmodeled crops.

Frequently asked questions

Why extend a plant-back interval for soil pH and rainfall? Because residuals like atrazine break down more slowly in high-pH, dry conditions, and the label says so. Encoding only the nominal interval passes a plan a dry year would fail.

What is the difference between a FLAG and a BLOCK? A BLOCK is inside the legally binding minimum interval; a FLAG means the nominal interval is met but a pH or rainfall condition extends it and the field needs review.

Can a carryover BLOCK ever be overridden? No — the labeled minimum is legal, not advisory. Only a conditional FLAG is eligible, and only when new measured evidence re-runs the check to SAFE.

Up: Rotation Constraint Modeling · Season Planning & Crop Rotation Optimization