Converting Prescriptions Between Controller Formats
A variable-rate prescription that runs perfectly on a John Deere 4640 display can be rejected outright by a Trimble GFX-750 or silently misapplied by an older Raven Viper. The geometry is identical, the agronomy is identical — what differs is the container. ISOXML wraps the prescription in an ISO 11783-10 TASKDATA.XML bundle with WGS84 geometry and integer-scaled rates; Trimble, Raven, and older CNH displays expect a Shapefile rate map with a short attribute field in a projected CRS; a farm-management information system (FMIS) usually wants GeoJSON. This guide builds a format-agnostic conversion layer with geopandas that normalises any of these into a common polygon-and-rate model, then re-emits it into whichever container the destination controller expects — with unit conversion, attribute mapping, CRS alignment, and a round-trip check that proves no rate drifted.
This page is part of the Yield Mapping & Variable Rate Prescription Generation section. See that overview for the full path from yield data through zone delineation to the finished prescription that this conversion layer consumes.
Prerequisites
Python packages (exact versions tested):
geopandas==0.14.4shapely==2.0.4pyproj==3.6.1fiona==1.9.6lxml==5.2.1(ISOXML parsing/writing)numpy==1.26.4
Install with:
pip install geopandas==0.14.4 shapely==2.0.4 pyproj==3.6.1 fiona==1.9.6 lxml==5.2.1 numpy==1.26.4
Input data requirements:
- A prescription in one of three source shapes: an ISOXML
TASKDATA.XMLbundle (with its.bingrid files if present), a rate Shapefile (.shp/.shx/.dbf/.prjset), or a GeoJSONFeatureCollection. - Every source must resolve to a CRS. GeoJSON and ISOXML are WGS84 (EPSG:4326) by definition; a Shapefile without a
.prjsidecar has an undefined CRS and must be assigned one before conversion — never assume. - Polygon or multipolygon geometry with a numeric rate attribute. Grid-based ISOXML (
TreatmentZonesreferencing a raster.bin) is vectorised to polygons before entering the common model. - The rate’s unit must be known: mass (kg/ha, lb/ac), seeding (seeds/ac, seeds/ha), or volume (L/ha, gal/ac). A bare number with no unit cannot be converted safely.
This conversion layer sits downstream of prescription generation. If you are producing the ISOXML in the first place, start from variable rate export to ISOXML; the round-trip validation here complements the schema checks described in validating ISOXML against the ISO 11783 schema.
1. Concept & the Common Model
Point-to-point converters — an “ISOXML to Shapefile” script, a separate “Shapefile to GeoJSON” script — multiply combinatorially. Three formats need six one-way converters; add a fourth and you need twelve. The maintainable pattern is a hub: every format parses into one internal representation, and every format serialises out of it. Adding a format costs one reader and one writer, not a fan of pairwise converters.
The internal representation — the common model — is deliberately minimal: a geopandas.GeoDataFrame in a known CRS with exactly three semantic columns plus geometry.
from dataclasses import dataclass
import geopandas as gpd
# The common model is a GeoDataFrame with these three columns + geometry:
# zone_id : int stable identifier for the management zone
# rate : float the application rate, ALWAYS stored in canonical units
# unit : str the canonical unit token, e.g. "kg_ha"
# CRS is carried by the GeoDataFrame itself (gdf.crs), never inferred later.
CANONICAL_UNIT = "kg_ha" # mass prescriptions normalise to kg/ha internally
@dataclass(frozen=True)
class PrescriptionMeta:
product: str # e.g. "urea_46_0_0" or "corn_seed"
source_format: str # "isoxml" | "shapefile" | "geojson"
source_crs: str # EPSG string as parsed, e.g. "EPSG:4326"
Why store the unit as a column rather than converting on read? Because the failure mode that ruins field applications is a silent unit mismatch — a lb/ac number applied as kg/ha over-applies by 1.12×, and a seeds/ac number written into a mass field can be off by orders of magnitude. Keeping the unit explicit and canonicalising once, at the export boundary, makes the conversion auditable. The controller itself performs no sanity check: it applies exactly the number and unit code you hand it.
What each ecosystem expects. ISOXML (ISO 11783-10) is the native language of modern ISOBUS terminals and the John Deere GreenStar / Operations Center stack. It stores geometry as WGS84 lon/lat and rates as scaled integers under a ProcessDataVariable with a DDI (data dictionary identifier) that fixes the unit — DDI 0006 is mass per area in mg/m², so a kg/ha value is multiplied by 100 to become an integer. Shapefile rate maps, consumed by Trimble, Raven Viper, and older CNH/Case IH displays, carry the rate in a DBF attribute column whose name must be ≤ 10 characters (a hard limit of the dBASE format) and typically live in a projected CRS with a .prj sidecar. GeoJSON is the interchange format for FMIS platforms and web tooling: WGS84 by the RFC 7946 spec, with the rate as an ordinary feature property.
Format compatibility matrix
| Capability | ISOXML (ISO 11783-10) | Shapefile rate map | GeoJSON (FMIS) |
|---|---|---|---|
| Primary consumers | John Deere GreenStar / Operations Center, ISOBUS terminals | Trimble, Raven Viper, older CNH/Case IH | FMIS platforms, web maps |
| Geometry model | WGS84 polygons + optional raster grid (.bin) |
Projected polygons (.shp) |
WGS84 polygons (RFC 7946) |
| Required CRS | EPSG:4326 (mandatory) | Any projected CRS + .prj (often UTM) |
EPSG:4326 (mandatory) |
| Rate storage | Scaled integer under a DDI | DBF attribute, field name ≤ 10 chars | Feature property (float) |
| Unit encoding | Fixed by DDI (e.g. mg/m²) | Implicit — convention only | Implicit — convention only |
| Multi-product | Native (multiple ProcessDataVariable) |
One rate per layer — flatten or split | One property per product |
| Attribute name limit | N/A (DDI-keyed) | 10 characters (hard) | None |
The matrix drives the conversion rules: any target in the EPSG:4326 column needs a reprojection step; the Shapefile target needs field-name truncation; and any conversion out of ISOXML’s DDI-fixed units into an ecosystem with only implicit units means the unit convention must be pinned by the operator, because the file no longer carries it.
2. Step-by-Step Implementation
Step 1 — Parse each source into the common model
Each reader returns the same shape: a GeoDataFrame with zone_id, rate, unit, geometry, and a known CRS. The Shapefile and GeoJSON readers are thin wrappers over geopandas.read_file; the ISOXML reader extracts TreatmentZone polygons and their ProcessDataVariable rate from TASKDATA.XML.
import geopandas as gpd
from shapely.geometry import Polygon
from lxml import etree
def read_shapefile_rx(path: str, rate_field: str, unit: str) -> gpd.GeoDataFrame:
"""Read a Trimble/Raven-style rate Shapefile into the common model."""
gdf = gpd.read_file(path)
if gdf.crs is None:
raise ValueError(f"{path} has no .prj — assign a CRS explicitly before converting")
gdf = gdf.rename(columns={rate_field: "rate"})
gdf["zone_id"] = range(1, len(gdf) + 1)
gdf["unit"] = unit
return gdf[["zone_id", "rate", "unit", "geometry"]]
def read_isoxml_rx(taskdata_path: str) -> gpd.GeoDataFrame:
"""Extract TreatmentZone polygons + rate from a TASKDATA.XML file (WGS84)."""
tree = etree.parse(taskdata_path)
root = tree.getroot()
records = []
for zi, tzn in enumerate(root.iter("TZN"), start=1): # TreatmentZone
pdv = tzn.find(".//PDV") # ProcessDataVariable
scaled = int(pdv.get("A")) # scaled integer value
rate_kg_ha = scaled / 100.0 # DDI 0006 mg/m² -> kg/ha
rings = []
for pnt in tzn.iter("PNT"): # polygon vertices
rings.append((float(pnt.get("C")), float(pnt.get("D")))) # lon, lat
if len(rings) >= 3:
records.append({"zone_id": zi, "rate": rate_kg_ha,
"unit": "kg_ha", "geometry": Polygon(rings)})
gdf = gpd.GeoDataFrame(records, crs="EPSG:4326")
return gdf[["zone_id", "rate", "unit", "geometry"]]
Validate every parse before it enters the pipeline:
def assert_common_model(gdf: gpd.GeoDataFrame) -> None:
assert gdf.crs is not None, "Common model requires an explicit CRS"
assert set(["zone_id", "rate", "unit"]).issubset(gdf.columns), "Missing model columns"
assert gdf["rate"].notna().all(), "Null rate values — inspect the source"
assert (gdf["rate"] >= 0).all(), "Negative rate — likely a parse or sign error"
assert gdf.geometry.is_valid.all(), "Invalid geometry — repair before converting"
print(f"OK: {len(gdf)} zones, unit={gdf['unit'].iat[0]}, CRS={gdf.crs.to_epsg()}")
Invalid geometry surfaces here rather than at the controller. Repairing it — buffering by zero, or shapely.make_valid — is covered in debugging shapefile geometry errors in QGIS and Python.
Step 2 — Convert rate units and round to controller resolution
Unit conversion happens once, at the export boundary, using an explicit factor table. Never convert implicitly on read.
# Multiplicative factors INTO the canonical kg/ha, and back out again.
TO_KG_HA = {
"kg_ha": 1.0,
"lb_ac": 1.12085, # 1 lb/ac = 1.12085 kg/ha
"kg_ha_seed": 1.0, # already mass-equivalent; kept distinct for clarity
}
FROM_KG_HA = {u: 1.0 / f for u, f in TO_KG_HA.items()}
# Seeding prescriptions are a count, not a mass — handled on a separate track.
SEEDS_AC_TO_SEEDS_HA = 2.47105 # 1 seed/ac = 2.47105 seeds/ha
def convert_rate(gdf: gpd.GeoDataFrame, target_unit: str, decimals: int = 1) -> gpd.GeoDataFrame:
"""Convert the common model's canonical rate to the target unit and round."""
src_unit = gdf["unit"].iat[0]
if src_unit not in TO_KG_HA or target_unit not in FROM_KG_HA:
raise ValueError(f"Unsupported unit pair {src_unit} -> {target_unit}")
canonical = gdf["rate"] * TO_KG_HA[src_unit] # into kg/ha
out = gdf.copy()
out["rate"] = (canonical * FROM_KG_HA[target_unit]).round(decimals)
out["unit"] = target_unit
return out
Rounding is not cosmetic. Trimble and Raven displays resolve rates to a fixed precision (commonly 0.1 units), and an unrounded float like 168.4523809 written to the DBF will be truncated by the display anyway — round explicitly so the file on disk matches what the controller applies, and so the round-trip check in Step 5 has a defined tolerance.
Step 3 — Map attribute and field names to the target
The Shapefile DBF format truncates field names to 10 characters. geopandas will silently truncate on write, which means a round trip through a Shapefile can rename application_rate to applicatio and break the reader that expects the original. Map names explicitly.
# Per-ecosystem attribute name for the rate column.
RATE_FIELD = {
"isoxml": None, # DDI-keyed, no textual field name
"shapefile": "RATE", # <= 10 chars; Trimble/Raven convention
"geojson": "rate_kg_ha", # descriptive, no length limit
}
def map_rate_field(gdf: gpd.GeoDataFrame, target_format: str) -> gpd.GeoDataFrame:
field = RATE_FIELD[target_format]
if field is None:
return gdf
assert len(field) <= 10 or target_format != "shapefile", "Shapefile field > 10 chars"
return gdf.rename(columns={"rate": field})
Step 4 — Reproject to the target CRS
The compatibility matrix fixes the rule: ISOXML and GeoJSON targets must be EPSG:4326; a Shapefile target is reprojected to whatever projected CRS the destination display expects — commonly the field’s UTM zone. Set it explicitly rather than trusting the source CRS.
def reproject_for_target(gdf: gpd.GeoDataFrame, target_format: str,
shapefile_epsg: int = 32615) -> gpd.GeoDataFrame:
"""Align CRS to the target ecosystem. shapefile_epsg = the field's UTM zone."""
target_epsg = 4326 if target_format in ("isoxml", "geojson") else shapefile_epsg
if gdf.crs.to_epsg() != target_epsg:
gdf = gdf.to_crs(epsg=target_epsg)
assert gdf.crs.to_epsg() == target_epsg, "Reprojection did not land on target CRS"
return gdf
Getting the projected CRS right is the most common cause of a prescription that loads but sits offset from the field. The full diagnostic workflow lives in validating coordinate systems for variable-rate maps.
Step 5 — Write the target and round-trip validate
Serialise to the target container, then read it back and assert that geometry area and rate survived the trip within tolerance.
def write_shapefile_rx(gdf: gpd.GeoDataFrame, out_path: str) -> str:
gdf.to_file(out_path, driver="ESRI Shapefile") # writes .shp/.shx/.dbf/.prj
return out_path
def round_trip_check(original: gpd.GeoDataFrame, written_path: str,
rate_field: str, rate_tol: float = 0.05) -> None:
"""Read the written file back and confirm rate + area parity."""
back = gpd.read_file(written_path)
assert len(back) == len(original), "Zone count changed on write"
# Compare rates in the common CRS-independent sense (sorted by zone)
orig_rates = original.sort_values("zone_id")["rate"].to_numpy()
back_rates = back.sort_values("zone_id")[rate_field].to_numpy()
max_dev = float(abs(orig_rates - back_rates).max())
assert max_dev <= rate_tol, f"Rate drift {max_dev:.3f} exceeds tolerance {rate_tol}"
print(f"Round trip OK: {len(back)} zones, max rate deviation {max_dev:.3f}")
The full converter wires these steps together:
def convert_prescription(src_gdf: gpd.GeoDataFrame, target_format: str,
target_unit: str, out_path: str,
shapefile_epsg: int = 32615) -> str:
assert_common_model(src_gdf)
gdf = convert_rate(src_gdf, target_unit)
gdf = reproject_for_target(gdf, target_format, shapefile_epsg)
gdf = map_rate_field(gdf, target_format)
if target_format == "shapefile":
write_shapefile_rx(gdf, out_path)
round_trip_check(convert_rate(src_gdf, target_unit), out_path, RATE_FIELD["shapefile"])
elif target_format == "geojson":
gdf.to_file(out_path, driver="GeoJSON")
return out_path
The concrete ISOXML-grid-to-Trimble-Shapefile case — including grid vectorisation and the 10-character field the Trimble GFX expects — is worked end to end in converting ISOXML to Shapefile for Trimble.
3. Key Parameters & Tuning
| Parameter | Type | Default | Agronomic Effect |
|---|---|---|---|
target_unit |
str | kg_ha |
The single highest-impact setting. A wrong unit applies the right pattern at the wrong magnitude — lb_ac read as kg_ha over-applies fertiliser by 12%. Must match the product setup on the display. |
decimals |
int | 1 | Rounding resolution. 1 matches most Trimble/Raven displays. Setting 0 on a seeding prescription can collapse a 34,000–36,000 seeds/ac spread into a single population if the values were scaled. |
RATE_FIELD["shapefile"] |
str | RATE |
DBF field name, hard limit 10 chars. Must match the column the display’s prescription import maps to; a mismatch loads the geometry with a zero rate. |
shapefile_epsg |
int | 32615 | Projected CRS for Shapefile targets. Set to the field’s actual UTM zone; a wrong zone offsets the prescription by tens to hundreds of metres, misapplying across zone boundaries. |
rate_tol |
float | 0.05 | Round-trip acceptance band. Tighten to 0.01 for seeding (population sensitivity); loosen only if the display’s own precision is coarser than the tolerance. |
| ISOXML DDI scale | int | 100 | Integer scale factor for DDI 0006 (mg/m² ↔ kg/ha). A wrong scale silently multiplies or divides every rate by 100. Verify against the ISO 11783 data dictionary for the product’s DDI. |
4. Edge Cases & Failure Modes
Multi-product prescriptions cannot flatten into one Shapefile. A blended ISOXML task with independent nitrogen and seed ProcessDataVariable entries carries two rates per zone. A single Shapefile layer has one rate attribute, so the conversion must split into one Shapefile per product (rx_nitrogen.shp, rx_seed.shp) or target a GeoPackage that holds multiple layers. Attempting to cram both into one DBF drops the second product silently.
Field-name truncation corrupts the round trip. If your common-model rate column is named anything longer than 10 characters and you rely on geopandas to write it, the DBF stores a truncated name and the read-back looks for the original — the round-trip check in Step 5 catches this only because it reads back by the mapped field name. Always map to a ≤ 10-character name explicitly before writing a Shapefile, as validated in shapefile validation for farm equipment.
Grid-based ISOXML has no polygon geometry to read. Some ISOXML prescriptions store rates as a raster grid referenced by a .bin file plus a TreatmentZone bounding box, not as explicit polygons. The read_isoxml_rx polygon path returns nothing for these; you must vectorise the grid (each cell or each contiguous rate class becomes a polygon) before entering the common model.
Antimeridian and UTM-zone-straddling fields. A field near a UTM zone boundary reprojected to the wrong zone lands kilometres off. When the field spans two zones, pick the zone containing the field centroid and accept minor edge distortion, or convert per-management-zone. The WGS84-to-UTM selection logic is detailed in how to convert WGS84 to UTM for farm mapping.
Negative or zero rates from grid nodata. Vectorised grid prescriptions often carry a nodata sentinel (-9999 or 0) as a “no application” zone. If this reaches the rate column unfiltered, convert_rate will scale the sentinel into a nonsensical rate. Filter or remap nodata to an explicit zero-rate zone before Step 2.
5. Verification & Output Validation
Beyond the round-trip assert, run three ecosystem-specific checks before shipping a converted file to the operator:
import geopandas as gpd
def verify_converted(path: str, target_format: str, rate_field: str,
expected_epsg: int) -> dict:
gdf = gpd.read_file(path)
# 1. CRS matches the ecosystem's requirement
assert gdf.crs.to_epsg() == expected_epsg, (
f"CRS {gdf.crs.to_epsg()} != expected {expected_epsg} for {target_format}"
)
# 2. Rate column present, numeric, non-negative
assert rate_field in gdf.columns, f"Missing rate field {rate_field}"
assert gdf[rate_field].notna().all() and (gdf[rate_field] >= 0).all()
# 3. Shapefile field-name length limit respected
if target_format == "shapefile":
too_long = [c for c in gdf.columns if c != "geometry" and len(c) > 10]
assert not too_long, f"DBF field(s) exceed 10 chars: {too_long}"
report = {
"zones": len(gdf),
"crs": gdf.crs.to_epsg(),
"rate_min": float(gdf[rate_field].min()),
"rate_max": float(gdf[rate_field].max()),
}
print(report)
return report
verify_converted("rx_nitrogen.shp", "shapefile", "RATE", 32615)
Operator spot-check. Load the converted file on the actual display (or its desktop companion — John Deere Operations Center, Trimble Ag Software) and confirm three things: the prescription sits on the field, not offset; the rate legend spans the expected agronomic range (e.g. 140–190 kg/ha for a nitrogen top-dress, not 1.4–1.9 or 14,000); and the product/unit in the display’s task setup matches the unit you converted to. A prescription that passes every code assert but fails the “does the legend look agronomically sane” eyeball test is almost always a unit or DDI-scale error.
6. Integration with the Pipeline
This conversion layer is the last step before a prescription reaches the machine, and it consumes the output of everything upstream.
Upstream — where the prescription comes from. The common model is populated from a finished prescription. If that prescription originates as ISOXML, it is produced by the variable rate export to ISOXML workflow; converting it onward to a John Deere-consumable bundle is covered in exporting prescription maps to John Deere GreenStar format.
Sideways — validating the geometry you convert. Every Shapefile that enters or leaves the common model should pass the geometry and attribute checks in shapefile validation for farm equipment so that invalid rings or duplicate field names never reach a controller.
Downstream — the specific conversions. The flagship worked example, reading a TASKDATA.XML grid and writing a Trimble-ready Shapefile, is converting ISOXML to Shapefile for Trimble. When a conversion fails at the export boundary, the schema and geometry diagnostics in resolving ISOXML schema validation failures isolate whether the fault is in the source or the writer.
Frequently Asked Questions
Why does my converted rate come out ten times too high on the display?
This is almost always a unit mismatch between kilograms per hectare and pounds per acre, or a seeds-per-acre value written into a mass-rate field. The controller trusts the number and the unit code you supply and applies no sanity check. Store the unit explicitly in your common model and convert once at the export boundary, then verify a known zone by hand before writing the file.
Do I need to reproject a prescription to WGS84 before loading it on the terminal?
It depends on the ecosystem. ISOXML embeds geometry as WGS84 latitude and longitude, so an export targeting an ISOBUS terminal must be in EPSG:4326. Most Shapefile-based controllers accept a projected CRS but require a matching .prj file, and some older displays assume a specific UTM zone. Set the CRS explicitly for the target rather than relying on whatever the source carried.
Will converting to Shapefile lose the multiple products in a blended prescription?
A single Shapefile layer carries one rate attribute per polygon, so a two-product ISOXML task with independent nitrogen and seed rates cannot round-trip into one Shapefile without flattening. Write one Shapefile per product and keep the product identifier in the file name, or move to a GeoPackage that holds multiple layers in one container.
Related
- Converting ISOXML to Shapefile for Trimble — the full worked example reading TASKDATA.XML and writing a Trimble-ready rate Shapefile
- Variable Rate Export to ISOXML — producing the ISO 11783-10 bundle that this layer converts onward
- Exporting Prescription Maps to John Deere GreenStar Format — targeting the GreenStar / Operations Center ecosystem specifically
- Shapefile Validation for Farm Equipment — geometry and attribute checks every rate Shapefile must pass before a controller reads it
- Resolving ISOXML Schema Validation Failures — isolating source-versus-writer faults when a conversion is rejected