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.


Prescription format conversion map A hub diagram: ISOXML, Shapefile rate map, and GeoJSON each convert into and out of a central common polygon-and-rate model that carries geometry, rate, unit, and CRS, enabling any-to-any conversion between the three controller ecosystems. Common model polygon + rate + unit + CRS ISOXML ISO 11783-10 · WGS84 John Deere · ISOBUS Shapefile rate map projected CRS · .prj Trimble · Raven · CNH GeoJSON WGS84 · RFC 7946 FMIS · web maps

Prerequisites

Python packages (exact versions tested):

  • geopandas==0.14.4
  • shapely==2.0.4
  • pyproj==3.6.1
  • fiona==1.9.6
  • lxml==5.2.1 (ISOXML parsing/writing)
  • numpy==1.26.4

Install with:

BASH
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.XML bundle (with its .bin grid files if present), a rate Shapefile (.shp/.shx/.dbf/.prj set), or a GeoJSON FeatureCollection.
  • Every source must resolve to a CRS. GeoJSON and ISOXML are WGS84 (EPSG:4326) by definition; a Shapefile without a .prj sidecar 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 (TreatmentZones referencing 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.

PYTHON
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

Convert through a model, never format to format Five conversion steps: read the source format, map it into an explicit common model of zones, rates, units and coordinate system, convert units deliberately, write the target format, and round-trip the result comparing zone areas and rates against the source. Read source ISOXML, SHP or GeoJSON To the common model zones, rates, units, CRS Convert units explicitly, never implied Write target the consumer's format Round-trip compare areas and rates Direct format-to-format conversion multiplies pairs: five formats need twenty converters, or ten if everything goes through one model.

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.

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

PYTHON
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.

PYTHON
# 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.

PYTHON
# 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.

PYTHON
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.

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

PYTHON
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.

What a format conversion keeps, and what it quietly drops A table of four prescription properties and whether each survives a format conversion: zone geometry survives with care over ring order, rate values survive only if their units travel with them, product identity often does not, and application timing rarely does. Property Survives conversion? What to do Zone geometry polygons Yes, with care over rings Normalise ring order on write Rate values numbers Only with their units Carry units in the model Product identity what is applied Often not Map product codes explicitly Application timing when and by whom Rarely Accept the loss, record it

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:

PYTHON
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.