Variable-Rate Prescription Maps
A variable-rate application (VRA) prescription is the artifact that turns an agronomic intention into machine instructions: a set of field polygons, each carrying the rate a sprayer or spreader should apply while its section controller is inside that polygon. Producing one is deceptively easy to get subtly wrong. The zones can be agronomically perfect and still export a rate that violates a product label, or reference acreage that a waterway setback forbids treating, or disagree with the field outline the task controller actually holds. This subsystem, part of Season Planning & Crop Rotation Optimization, owns the path from management zones to a defensible, regulation-clamped prescription — and its non-negotiable rule is that no zone rate ever leaves the pipeline above the regulated ceiling for the product being applied.
Problem framing: an agronomic optimum is not a legal rate
Zone delineation and rate agronomy pull in one direction — put more nitrogen where the yield potential justifies it, less where it does not. Regulation pulls in another. A product label carries a maximum application rate derived from EPA registration and 40 CFR tolerances, water features impose setback exclusions, and the field the prescription is written against must be the same polygon the machine believes it is working. A prescription generator that optimizes rate without enforcing all three produces a file that looks correct in a desktop GIS and fails on the ground: the rate controller either applies an illegal rate or the operator overrides the map and the whole exercise is discarded.
The failure is quiet and expensive. An agronomic model asked for 260 kg/ha of nitrogen on a high-yield zone will happily emit it; if the label ceiling is 246 kg/ha, that 14 kg/ha excess is a reportable over-application on every hectare of that zone, discovered only when a season’s records are reconciled against the label. The remedy is structural, not advisory: every per-zone rate is passed through a single clamp against the regulated maximum before it can be serialized, the sprayable envelope is computed by subtracting buffer exclusions from the canonical field boundary, and any zone left with no treatable area is dropped rather than exported as a phantom instruction. This topic covers that pipeline end to end; two focused guides go deeper on the hardest legs — generating the nitrogen rate itself and exporting the result to ISOXML for task controllers.
Prerequisites and dependencies
The prescription generator sits downstream of boundary synchronization and the compliance registry, and upstream of the export format. Confirm four contracts before wiring it in.
- A canonical field boundary. Zones are reconciled against the authoritative field polygon, not against whatever outline shipped with the soil samples. Drift reconciliation and boundary versioning are owned by Field Boundary Synchronization; this pipeline consumes the boundary it publishes and never redraws it.
- Buffer-zone exclusions. The un-sprayable setback geometry around water features, wells, and sensitive areas is computed by the Buffer Zone Calculations engine. The prescription subtracts that geometry from the field before any rate is attributed to acreage.
- Regulated maximum rates. The per-product ceiling used by the clamp originates in EPA/USDA Rule Mapping and is resolved at runtime from a version-stamped registry — never hard-coded per client, so a label revision re-clamps every future export.
- A soil or yield sample layer. Management zones are derived from a per-point productivity index (soil test, historical yield, or a fused stability index) carried in a WGS84 point layer.
The stack is pinned for reproducibility across planning workers: geopandas>=0.14 and shapely>=2.0 for the spatial algebra, numpy>=1.26 for the zoning quantiles, and Python 3.10+ for match/case and X | None unions. Regulated rates are handled as Decimal end to end so that rounding for export never nudges a value across the ceiling. The governing external references are the EPA pesticide label and use-rate requirements, EPA 40 CFR pesticide tolerances, and USDA NRCS nutrient management (Code 590) guidance for agronomic rate ceilings.
Architecture of this subsystem
The generator is a five-stage transform. A sample layer is binned into management zones; each zone is assigned an agronomic rate; every rate is clamped to the regulated maximum; the zones are reconciled against the canonical boundary minus buffer exclusions; and the surviving zones are serialized as a prescription. The clamp and the reconciliation both draw on the compliance registry, and every stage writes an entry to an append-only audit ledger recording the requested rate, the clamped rate, and the boundary version it was reconciled against.
The inputs are a WGS84 sample layer, the canonical field boundary, the buffer exclusion geometry, and an agronomic target; the outputs are a list of clamped, reconciled zone prescriptions ready for serialization. The integration is one-directional: this service consumes boundaries and registry values and produces a prescription — it never edits the boundary registry or the compliance thresholds it reads.
Step-by-step implementation
The reference generator below is a single module with four composable functions plus an export step. It targets Python 3.10+ and is fully typed.
Step 1 — Derive management zones
Management zones are equal-count bins of a per-point productivity index. Quantile edges track the yield or soil gradient without assuming a particular distribution, and the CRS is asserted up front because every later area and reconciliation calculation depends on it.
| Parameter | Type | Default | Purpose |
|---|---|---|---|
samples |
GeoDataFrame |
— | WGS84 point layer carrying the productivity index. |
n_zones |
int |
4 |
Number of management zones; equal-count quantile bins. |
index_col |
str |
— | Column holding the soil/yield productivity index. |
Step 2 — Assign per-zone rates and clamp to the regulated maximum
Each zone’s mean index is mapped onto an agronomic response between a target floor and ceiling, in Decimal. The proposed rate is then passed through clamp_to_regulated_max, which replaces any value above the label ceiling with the ceiling and records that the zone was clamped. This is the guarantee the whole subsystem exists to provide: nothing downstream can raise a rate back above the cap.
| Parameter | Type | Default | Purpose |
|---|---|---|---|
target.floor |
Decimal |
— | Minimum agronomic rate (kg/ha) for the lowest zone. |
target.ceiling |
Decimal |
— | Maximum agronomic rate before the regulatory clamp. |
product |
str |
— | Registry key selecting the regulated ceiling to clamp against. |
Step 3 — Reconcile against boundaries and export
The sprayable envelope is the canonical field boundary with buffer exclusions subtracted. Each zone is intersected with that envelope; a zone that keeps no area is dropped, not exported as a zero-area instruction. The survivors are written to a GIS layer that the ISOXML exporter consumes.
from __future__ import annotations
import logging
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
import geopandas as gpd
import numpy as np
from shapely.geometry import base
logger = logging.getLogger("vra.prescription")
# Regulated per-product ceilings are resolved from the compliance registry at
# runtime; the literals here stand in for that lookup. Units are kg/ha of a.i.
EPA_MAX_KG_HA: dict[str, Decimal] = {
"nitrogen": Decimal("246.0"),
"phosphorus": Decimal("90.0"),
}
QUANTIZE = Decimal("0.1") # export resolution the task controller accepts
@dataclass(frozen=True)
class AgronomicTarget:
product: str
floor: Decimal # lowest agronomic rate (kg/ha)
ceiling: Decimal # highest agronomic rate BEFORE the regulatory clamp
@dataclass(frozen=True)
class ZonePrescription:
zone_id: int
rate_kg_ha: Decimal
clamped: bool
area_ha: float
geometry: base.BaseGeometry # WGS84 (EPSG:4326)
def derive_zones(samples: gpd.GeoDataFrame, n_zones: int, index_col: str) -> gpd.GeoDataFrame:
"""Bin a per-point productivity index into ordered zones via numpy quantiles."""
if samples.crs is None or samples.crs.to_epsg() != 4326:
raise ValueError("sample layer must be WGS84 (EPSG:4326)")
values = samples[index_col].to_numpy(dtype=float)
# Quantile edges give equal-count zones that follow the yield/soil gradient.
edges = np.quantile(values, np.linspace(0.0, 1.0, n_zones + 1))
edges[0], edges[-1] = -np.inf, np.inf # keep the open ends inclusive
zone_id = np.digitize(values, edges[1:-1], right=False)
zoned = samples.assign(zone_id=zone_id)
# Dissolve the points of each zone into one polygon (Voronoi/buffer upstream).
return zoned.dissolve(by="zone_id", aggfunc={index_col: "mean"}).reset_index()
def assign_rate(zone_frac: float, target: AgronomicTarget) -> Decimal:
"""Map a zone's normalized index (0..1) to an agronomic rate in kg/ha."""
span = target.ceiling - target.floor
frac = Decimal(str(max(0.0, min(1.0, zone_frac)))) # guard the interpolation
return (target.floor + span * frac).quantize(QUANTIZE, rounding=ROUND_HALF_UP)
def clamp_to_regulated_max(rate: Decimal, product: str) -> tuple[Decimal, bool]:
"""CLAMP a proposed rate to the regulated ceiling. Returns (rate, was_clamped)."""
ceiling = EPA_MAX_KG_HA[product]
if rate > ceiling:
logger.warning(
'{"event":"rate_clamped","product":"%s","requested":"%s","ceiling":"%s"}',
product, rate, ceiling,
)
return ceiling, True
return rate, False
def _area_hectares(geom: base.BaseGeometry) -> float:
"""Area in hectares via an equal-area reprojection (EPSG:6933)."""
projected = gpd.GeoSeries([geom], crs=4326).to_crs(6933)
return float(projected.area.iloc[0]) / 10_000.0
def build_prescription(
zones: gpd.GeoDataFrame,
field_boundary: base.BaseGeometry,
buffer_exclusions: base.BaseGeometry,
target: AgronomicTarget,
index_col: str,
) -> list[ZonePrescription]:
"""Zone -> rate -> clamp -> reconcile against the canonical boundary and buffers."""
plan_area = field_boundary.difference(buffer_exclusions) # sprayable acreage only
idx = zones[index_col].to_numpy(dtype=float)
lo, hi = float(idx.min()), float(idx.max())
out: list[ZonePrescription] = []
for row in zones.itertuples(index=False):
# Uniform index (hi == lo) degrades to a flat plan instead of div-by-zero.
frac = 0.0 if hi == lo else (getattr(row, index_col) - lo) / (hi - lo)
rate = assign_rate(frac, target)
rate, clamped = clamp_to_regulated_max(rate, target.product)
geom = row.geometry.intersection(plan_area) # keep only sprayable part
if geom.is_empty:
logger.info(
'{"event":"zone_dropped","zone_id":%d,"reason":"outside_plan_area"}',
int(row.zone_id),
)
continue
out.append(
ZonePrescription(int(row.zone_id), rate, clamped, _area_hectares(geom), geom)
)
logger.info(
'{"event":"prescription_built","zones":%d,"clamped":%d}',
len(out), sum(p.clamped for p in out),
)
return out
def export_prescription(rx: list[ZonePrescription], path: str) -> None:
"""Serialize the clamped, reconciled zones to a GIS layer for the controller."""
frame = gpd.GeoDataFrame(
{
"zone_id": [p.zone_id for p in rx],
"rate_kg_ha": [float(p.rate_kg_ha) for p in rx],
"clamped": [p.clamped for p in rx],
"area_ha": [round(p.area_ha, 4) for p in rx],
},
geometry=[p.geometry for p in rx],
crs="EPSG:4326",
)
frame.to_file(path, driver="GeoJSON")
logger.info('{"event":"prescription_exported","path":"%s","zones":%d}', path, len(rx))
Expected log output when one high-yield zone is clamped to the label ceiling:
{"event": "rate_clamped", "product": "nitrogen", "requested": "261.4", "ceiling": "246.0"}
{"event": "prescription_built", "zones": 4, "clamped": 1}
{"event": "prescription_exported", "path": "field_12_rx.geojson", "zones": 4}
Expected log output when a zone falls entirely inside a buffer exclusion:
{"event": "zone_dropped", "zone_id": 0, "reason": "outside_plan_area"}
{"event": "prescription_built", "zones": 3, "clamped": 0}
Edge cases and known failure modes
| Condition | Symptom | Fix |
|---|---|---|
| Agronomic model requests a rate above the label ceiling | Export would carry an illegal over-application on every hectare of the zone | clamp_to_regulated_max caps the value at the ceiling, sets clamped=True, and logs rate_clamped. |
| A zone lies entirely within a buffer exclusion | Zone would export a rate for un-sprayable acreage | The intersection empties; the zone is dropped with zone_dropped and never serialized. |
| Sample layer is not WGS84 | Areas and reconciliation are silently wrong | derive_zones asserts EPSG:4326 and raises before any zoning happens. |
| Zones and boundary come from different vintages | Sliver gaps/overlaps at field edges | Reconcile against the current boundary from Field Boundary Synchronization; snap zones to the active version. |
| All samples share one index value | Quantile edges collapse to a single zone | The hi == lo guard yields a uniform rate — a flat-rate plan rather than a divide-by-zero. |
| Buffer exclusion geometry is self-intersecting | difference raises TopologyException |
Repair with make_valid upstream; the buffer engine must publish valid geometry. |
| Rounding to export resolution nudges a rate above the ceiling | Controller rejects the zone | Quantize first, then clamp — the clamp is always the final gate before export. |
Compliance and audit integration
Every prescription is a compliance artifact, and the clamp is where the compliance boundary is enforced. The regulated maximum for each product is resolved from the versioned threshold set in EPA/USDA Rule Mapping, which draws the ceiling from the product’s EPA pesticide label and the applicable 40 CFR tolerance. Because the ceiling is looked up rather than hard-coded, a label revision that lowers a maximum re-clamps every prescription generated afterward, and a prescription generated last season replays against the ceiling that was in force at the time.
Buffer exclusions carry the second half of the compliance guarantee. The setback geometry the reconciliation subtracts is produced by the Buffer Zone Calculations engine from EPA Worker Protection Standard and label setback rules; treating acreage inside a setback is a violation regardless of the rate applied. Each zone’s audit entry records the requested rate, the clamped rate, whether the clamp fired, the boundary version reconciled against, and the excluded acreage removed — a continuous trail from an exported prescription back to the agronomic target and the regulatory ceiling that constrained it. Following USDA NRCS Code 590 nutrient-management expectations, the clamped-zone count and total excluded area should be surfaced as planning-health metrics.
Verification
Confirm correct operation in staging with a reproducible fault-injection scenario before promoting a change:
- Golden prescription. Feed a four-zone sample layer whose highest agronomic rate sits just under the ceiling and assert
build_prescriptionreturns four zones,clamped=Falseon all of them, and areas that sum to the sprayable envelope. - Inject an over-ceiling zone. Set the target ceiling above the regulated maximum so the top zone would exceed it, and assert that zone’s exported rate equals the registry ceiling exactly,
clamped=True, and arate_clampedentry is written — the over-application never reaches the file. - Inject a fully-excluded zone. Place a zone entirely inside the buffer geometry and assert it is dropped with
zone_dropped, the exported zone count drops by one, and no zero-area geometry is serialized. - Inject a wrong CRS. Pass a sample layer in a projected CRS and assert
derive_zonesraises before any rate is computed, so a mis-projected plan can never be exported.
A run passes only when each injected fault produces the expected safe result and the audit ledger holds one traceable entry per zone. The rate agronomy and the export format each have their own verification, covered in the guides below.
Frequently Asked Questions
Why clamp every zone instead of validating the prescription after it is built? Because a post-hoc validator can reject a bad file but cannot make a good one — it leaves the pipeline able to emit an illegal rate right up to the final check. Clamping inside the rate assignment means an over-ceiling value is impossible to serialize: the clamp replaces it with the ceiling, flags the zone, and logs the event, so the export is correct by construction rather than by a gate that a refactor could bypass.
What happens to a management zone that falls entirely inside a buffer exclusion?
It is dropped, not exported with a zero rate. The zone is intersected with the sprayable envelope (boundary minus buffers); if nothing survives, a zone_dropped entry is written and the zone never appears in the prescription. Exporting a zero-area or zero-rate instruction would clutter the task controller and obscure the fact that the acreage is legally un-treatable.
Why reconcile against the canonical boundary instead of the outline that came with the samples? Sample layers are frequently drawn against a stale or approximate field outline. Attributing rates to that outline produces slivers and phantom acreage where it disagrees with the boundary the machine actually holds. Reconciling against the versioned canonical boundary keeps the prescription geometry identical to the field the controller works, which is what makes the applied-area records defensible.
Why use Decimal for the rates rather than floats?
Regulated rates are compared against a hard ceiling and rounded to an export resolution. Binary floats make both operations imprecise — a value that should equal the ceiling can land a hair above it and trip a false violation, or a rounding step can drift. Decimal with an explicit quantization keeps the clamp exact and the exported value reproducible.
Related
- Generating Variable-Rate Nitrogen Prescription Maps — the yield-goal, soil-test, and MRTN math that produces the per-zone nitrogen rate this pipeline clamps.
- Exporting Prescription Maps to ISOXML for Task Controllers — serializing the reconciled prescription to ISO 11783-10 TASKDATA.
- Field Boundary Synchronization — the canonical boundary registry every zone is reconciled against.
- Buffer Zone Calculations — the setback engine supplying the exclusion geometry subtracted from the field.
- EPA/USDA Rule Mapping — the versioned threshold set supplying the regulated maximum the clamp enforces.