Variable Rate Export to ISOXML: Python Workflow for Precision Agriculture
Generating compliant ISOXML packages is the final, and often the most fragile, stage of any prescription pipeline. ISO 11783-10 (ISOXML) standardises how prescription maps, task definitions, and equipment configurations are exchanged across tractor task controllers, sprayer rate valves, and granule spreaders. A mis-encoded namespace declaration, a coordinate rounded to the wrong precision, or a PDT unit string that does not match the implement’s firmware can cause silent misapplication or outright terminal rejection — with no visible error until the applicator is already moving through the crop.
This guide walks through a complete Python export pipeline: from loading the prescription output of your spatial interpolation or management zone classification step, through ISOXML construction and XSD validation, to a USB-ready archive. All geometry work should be preceded by shapefile validation for farm equipment to catch topology violations before they corrupt the XML layer. The result is a deterministic, schema-validated TASKDATA.XML package that ISOBUS task controllers can parse without manual intervention.
This page is part of the Yield Mapping & Variable Rate Prescription Generation workflow — see there for the full pipeline context.
Prerequisites
| Dependency | Version | Role |
|---|---|---|
geopandas |
≥ 0.14 | Geometry I/O, CRS transformation |
shapely |
≥ 2.0 | Topology repair |
pyproj |
≥ 3.4 | CRS objects and EPSG lookups |
lxml |
≥ 4.9 | XML construction, namespace handling, XSD validation |
numpy |
≥ 1.25 | Rate array operations |
rasterio |
≥ 1.3 | Grid-prescription raster reading (optional path) |
Input data requirements:
- Prescription source: either a polygon GeoDataFrame with an application-rate column (zone path), or a single-band GeoTIFF with float32 rates (grid path).
- Rate column / band units must be documented:
kg_ha,L_ha, orlbs_ac— these must match the PDT unit string expected by your target terminal. - Field boundary polygon in any projected CRS (will be reprojected at export).
- CRS: any EPSG-registered projection is accepted as input; the pipeline normalises to EPSG:4326 for output.
Hardware assumptions: the export targets ISOXML version 4.x as implemented on mainstream ISOBUS Task Controller Class 2+ terminals (John Deere Greenstar Gen 3/4 in AEF-compliant mode, AGCO Precision Planting, CNH AFS). Proprietary extensions are not required but are noted where they arise.
Concept & Algorithm
ISO 11783-10 defines a task-controller data model built around a handful of core XML elements. The ones that matter for prescription export are:
- TASKDATA — the document root; carries version and software-manufacturer metadata.
- PDT (Product) — describes what is being applied (nitrogen fertiliser, herbicide, seed) with units and reference ID.
- PTO (Product Allocation) — links a PDT to a specific field task and lists the per-zone or per-cell target rates via ProcessDataVariable attributes.
- TZN (Treatment Zone) — a polygon zone carrying a uniform target-rate value; maps directly to management-zone GeoDataFrame rows.
- GRD (Grid) — a fixed-cell raster grid encoding rates column-by-column; maps directly to GeoTIFF raster data.
The choice between TZN (zone) and GRD (grid) depends on how the prescription was generated. Zone prescriptions that came out of a management zone classification step are naturally TZN-based. Continuous-surface prescriptions produced by kriging or IDW interpolation are better serialised as GRD to preserve spatial resolution without polygon over-simplification.
Step-by-Step Implementation
Step 1 — Normalize Geometry and Reproject to WGS84
ISOXML terminal parsers expect geographic coordinates in WGS84 (EPSG:4326). Do all area and distance calculations in a metric projected CRS first, then reproject only at export time. Topology repair must happen before reprojection so that make_valid operates in metric space where distances are meaningful.
import geopandas as gpd
from shapely.validation import make_valid
from pyproj import CRS
import logging
logger = logging.getLogger(__name__)
def normalize_prescription(input_path: str, metric_epsg: int = 32632) -> gpd.GeoDataFrame:
"""
Load, repair, and validate a prescription GeoDataFrame.
Repairs topology in the supplied metric CRS, then reprojects to EPSG:4326.
Args:
input_path: Path to a shapefile or GeoPackage.
metric_epsg: EPSG code for the local metric CRS used during repair.
Default 32632 = UTM zone 32N (central Europe).
Adjust to the field's UTM zone before use.
Returns:
GeoDataFrame in EPSG:4326, topology-clean, coordinate-rounded.
Raises:
ValueError: If the dataset is empty or coordinates fall outside WGS84 bounds.
"""
gdf = gpd.read_file(input_path)
if gdf.empty:
raise ValueError("Input dataset contains no features.")
# Repair in metric CRS so buffer distances are meaningful
if gdf.crs is None:
raise ValueError("Input has no CRS. Assign EPSG before calling this function.")
metric_crs = CRS.from_epsg(metric_epsg)
if gdf.crs != metric_crs:
logger.info("Reprojecting to metric CRS %s for topology repair", metric_epsg)
gdf = gdf.to_crs(metric_crs)
gdf["geometry"] = gdf["geometry"].apply(make_valid)
gdf = gdf[~gdf["geometry"].is_empty].reset_index(drop=True)
# Reproject to WGS84 for ISOXML output
wgs84 = CRS.from_epsg(4326)
gdf = gdf.to_crs(wgs84)
# Validate geographic bounds
min_lon, min_lat, max_lon, max_lat = gdf.total_bounds
assert -180 <= min_lon <= max_lon <= 180, "Longitude out of WGS84 range"
assert -90 <= min_lat <= max_lat <= 90, "Latitude out of WGS84 range"
# Round to 7 d.p. (~1 cm) to avoid firmware truncation artifacts
from shapely import set_precision
gdf["geometry"] = gdf["geometry"].apply(lambda g: set_precision(g, grid_size=1e-7))
logger.info("Normalized %d features, CRS: EPSG:4326", len(gdf))
return gdf
Verify the step succeeded before continuing:
gdf = normalize_prescription("prescriptions/corn_n_zones.gpkg", metric_epsg=32615)
assert gdf.crs.to_epsg() == 4326, "CRS must be EPSG:4326 after normalization"
assert gdf.geometry.is_valid.all(), "Topology repair failed on at least one feature"
print(f"Features ready for export: {len(gdf)}, CRS: {gdf.crs}")
Step 2 — Build Product Definitions (PDT) and Rate Mapping
Product definitions tell the task controller what is being applied and in what units. The attribute letters (A, B, C …) are the ISO 11783-10 shorthand for element attribute indices — they are positional, not named, so ordering matters.
from lxml import etree
import uuid
NS = "urn:iso:std:iso:11783:-10:taskdata:1.0"
def build_pdt_element(product_id: str, name: str, unit: str = "mg1ha-1") -> etree._Element:
"""
Build a single PDT (Product) XML element.
ISOXML unit strings follow the DDI registry. Common values:
mg1ha-1 → kg/ha (mass per area, scaled ×1e6 internally)
ml1ha-1 → L/ha (volume per area)
Always confirm the DDI string against the terminal's implementation guide.
"""
pdt = etree.Element("{%s}PDT" % NS, attrib={
"A": product_id, # Unique product reference ID
"B": name, # Human-readable name (max 32 chars)
"C": unit, # DDI unit string
"D": str(uuid.uuid4())[:8] # Optional manufacturer-assigned UID
})
return pdt
def map_zones_to_rates(gdf: gpd.GeoDataFrame, rate_col: str, product_id: str) -> list[dict]:
"""
Extract zone geometry and rate metadata for TZN generation.
Args:
gdf: GeoDataFrame in EPSG:4326 with a numeric rate column.
rate_col: Column name containing application rates in the PDT's unit.
product_id: Must match the 'A' attribute of the corresponding PDT element.
Returns:
List of dicts with keys: zone_id, geometry, rate, product_id.
Raises:
KeyError: If rate_col is absent.
ValueError: If any rate is negative or non-finite.
"""
if rate_col not in gdf.columns:
raise KeyError(f"Column '{rate_col}' not found. Available: {list(gdf.columns)}")
import numpy as np
rates = gdf[rate_col].to_numpy(dtype=float)
if not np.all(np.isfinite(rates)):
raise ValueError("Non-finite values detected in rate column. Fill or drop before export.")
if np.any(rates < 0):
raise ValueError("Negative application rates are invalid. Clamp to 0 before export.")
return [
{
"zone_id": f"Z{i+1:04d}",
"geometry": row.geometry,
"rate": float(row[rate_col]),
"product_id": product_id,
}
for i, row in gdf.iterrows()
]
Step 3 — Construct TASKDATA.XML
This is the core serialisation step. Element order within TASKDATA is significant; terminals validate child-element sequence against the XSD before reading attribute values.
def build_taskdata_xml(zones: list[dict], pdt_element: etree._Element,
task_name: str = "VRA_Prescription") -> bytes:
"""
Assemble a complete, namespace-correct TASKDATA.XML document.
Returns raw UTF-8 bytes (no BOM) ready for file write.
Element order: TASKDATA → PDT → TSK → TZN* → CTR* → LSG → LSG/PNT
"""
nsmap = {None: NS}
root = etree.Element("{%s}TASKDATA" % NS, nsmap=nsmap, attrib={
"VersionMajor": "4",
"VersionMinor": "3",
"ManagementSoftwareManufacturer": "ag-gis-exporter",
"ManagementSoftwareVersion": "1.0",
"DataTransferOrigin": "1", # 1 = FMIS (farm management information system)
})
# Attach product definition first — TSK references it by A-attribute
root.append(pdt_element)
# TSK (Task) element
tsk = etree.SubElement(root, "{%s}TSK" % NS, attrib={
"A": "TSK1",
"B": task_name,
"G": "1", # TaskStatus: 1 = planned
})
for zone in zones:
# TZN = Treatment Zone — one per management zone polygon
tzn = etree.SubElement(tsk, "{%s}TZN" % NS, attrib={
"A": zone["zone_id"],
})
# ProcessDataVariable: links zone to product and rate
etree.SubElement(tzn, "{%s}PDV" % NS, attrib={
"A": "0007", # DDI 0x0007 = Setpoint Application Rate
"B": str(int(round(zone["rate"] * 1e6))), # Value in mg1ha-1 units ×1e6
"C": zone["product_id"], # References PDT.A
})
# CTR (Polygon boundary) — serialize exterior ring
ctr = etree.SubElement(tzn, "{%s}CTR" % NS)
coords = list(zone["geometry"].exterior.coords)
for lon, lat in coords:
etree.SubElement(ctr, "{%s}PNT" % NS, attrib={
"A": "2", # Point type: 2 = field access/boundary
"C": f"{lat:.7f}", # Note: ISOXML uses lat (C) before lon (D)
"D": f"{lon:.7f}",
})
return etree.tostring(root, pretty_print=True,
xml_declaration=True, encoding="UTF-8")
Coordinate order gotcha: ISOXML specifies latitude in attribute
Cand longitude in attributeD— the reverse of GeoJSON/WKT convention. Swapping these is one of the most common causes of prescription zones appearing in the ocean on terminal preview maps.
Step 4 — Validate Against the XSD Schema
Always validate before packaging. The lxml.etree.XMLSchema validator catches structural violations that browser XML tools miss.
from lxml import etree
from pathlib import Path
def validate_isoxml(xml_bytes: bytes, xsd_path: str) -> None:
"""
Validate serialised ISOXML bytes against the ISO 11783-10 XSD.
Args:
xml_bytes: Output of build_taskdata_xml().
xsd_path: Path to the XSD file obtained from the terminal manufacturer
or the AEF ISOBUS implementation guide.
Raises:
etree.XMLSchemaError: With a descriptive message listing all violations.
"""
xsd_doc = etree.parse(xsd_path)
schema = etree.XMLSchema(xsd_doc)
doc = etree.fromstring(xml_bytes)
if not schema.validate(doc):
errors = "\n".join(str(e) for e in schema.error_log)
raise etree.XMLSchemaError(
f"ISOXML validation failed with {len(schema.error_log)} error(s):\n{errors}"
)
print("XSD validation passed.")
If you do not yet have the manufacturer’s XSD, run a structural sanity check as a minimum:
doc = etree.fromstring(xml_bytes)
# Assert required elements are present
assert doc.find(".//{%s}PDT" % NS) is not None, "Missing PDT element"
assert doc.find(".//{%s}TSK" % NS) is not None, "Missing TSK element"
tzn_count = len(doc.findall(".//{%s}TZN" % NS))
assert tzn_count == len(zones), f"TZN count {tzn_count} ≠ zone count {len(zones)}"
print(f"Structural check passed: {tzn_count} treatment zones serialised.")
Step 5 — Package and Deploy
ISOBUS task controllers read ISOXML from a specific directory structure on a FAT32 USB drive. The TASKDATA.XML file must sit at the root of an ISOXML/ folder (some terminals also accept a flat root layout — check the firmware guide).
import zipfile
import os
import tempfile
from pathlib import Path
def package_isoxml(xml_bytes: bytes, output_zip: str,
extra_files: dict[str, bytes] | None = None) -> Path:
"""
Package TASKDATA.XML (and any auxiliary files) into a terminal-ready ZIP.
Args:
xml_bytes: Validated XML as bytes.
output_zip: Output path for the ZIP archive.
extra_files: Optional dict mapping archive filename → bytes content
(e.g., {"TASKDATA.XSD": xsd_bytes} for self-validating packages).
Returns:
Path to the created ZIP file.
"""
out_path = Path(output_zip)
out_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf:
# Write TASKDATA.XML — no BOM, LF line endings enforced by lxml
zf.writestr("TASKDATA.XML", xml_bytes)
if extra_files:
for name, content in extra_files.items():
zf.writestr(name, content)
zip_size_kb = out_path.stat().st_size / 1024
print(f"Package created: {out_path} ({zip_size_kb:.1f} KB)")
return out_path
Deployment checklist before inserting the USB drive into the cab:
- USB formatted as FAT32 — exFAT is rejected by most ISOBUS terminals.
TASKDATA.XMLencoding: UTF-8 without BOM —lxmlwithencoding="UTF-8"handles this correctly;encoding="utf-8-sig"produces a BOM and will fail.- Verify line endings are LF (Unix), not CRLF —
lxmldefault is correct; do not open and re-save in Windows Notepad. - Run the package through a terminal emulator (e.g., the AGCO or John Deere developer sandbox) before field deployment.
Putting It All Together
from pathlib import Path
# ---- Inputs ----
INPUT_SHAPEFILE = "prescriptions/corn_n_zones.gpkg"
RATE_COLUMN = "n_kg_ha"
PRODUCT_NAME = "Nitrogen"
OUTPUT_ZIP = "exports/corn_n_vra.zip"
XSD_PATH = "schemas/TASKDATA.xsd" # Obtain from AEF or terminal OEM
# ---- Pipeline ----
# 1. Normalize
gdf = normalize_prescription(INPUT_SHAPEFILE, metric_epsg=32615)
# 2. Build product definition
pdt = build_pdt_element("PDT1", PRODUCT_NAME, unit="mg1ha-1")
# 3. Map zones
zones = map_zones_to_rates(gdf, RATE_COLUMN, product_id="PDT1")
# 4. Build XML
xml_bytes = build_taskdata_xml(zones, pdt, task_name="Corn_N_VRA_2024")
# 5. Validate
validate_isoxml(xml_bytes, XSD_PATH)
# 6. Package
package_isoxml(xml_bytes, OUTPUT_ZIP)
print("Export complete.")
Key Parameters & Tuning
| Parameter | Type | Default | Agronomic Effect |
|---|---|---|---|
metric_epsg |
int |
32632 |
UTM zone used for topology repair; must match field location or repair distances are distorted |
grid_size |
float |
1e-7 |
Coordinate rounding in degrees (~1 cm); coarser values reduce file size at cost of boundary accuracy |
DDI (Data Dictionary Identifier) |
str |
"0007" |
Selects the process variable type; 0007 = setpoint application rate; 0008 = actual rate (logged); check AEF DDI catalogue for herbicide flow and seed singulation codes |
rate_scale_factor |
int |
1e6 |
ISOXML stores rates as integers scaled by 1 million; adjust only if the DDI definition specifies a different resolution |
DataTransferOrigin |
str |
"1" |
1 = FMIS-generated; 2 = task controller-generated; terminals may route tasks differently depending on this flag |
VersionMajor / VersionMinor |
str |
4 / 3 |
ISO 11783-10 version; change only when explicitly targeting an older terminal firmware |
Handling Edge Cases & Failure Modes
Coordinate lat/lon swap. ISOXML uses C for latitude and D for longitude inside PNT elements — the opposite of GeoJSON. A swapped export produces zones that appear to be on the correct continent but offset by tens of kilometres. Always spot-check a single zone’s centroid lat/lon against a known field coordinate after export.
PDT unit string mismatch. If your rate is in kg/ha (mass) but the DDI registry string is mg1ha-1 (milligrams per hectare), the internal scale factor must be adjusted to ×1e6 so the integer-encoded value represents the correct quantity. Verify: rate_kg_ha * 1e6 should give a value in the range 0–500,000,000 for realistic nitrogen application rates; values outside this window indicate a unit mismatch.
Adjacent zone overlaps. Management zones from unsupervised clustering occasionally have hairline overlaps that pass is_valid checks but confuse the task controller’s spatial intersection logic. Apply a negative buffer followed by a positive buffer (morphological opening) to introduce clean separation: gdf["geometry"] = gdf.geometry.buffer(-0.1).buffer(0.1) in metric CRS before normalisation.
Zones smaller than implement swath width. ISOXML permits arbitrarily small polygons, but a zone narrower than the implement swath width (typically 12–36 m) creates rapid rate-change oscillations that exceed actuator response time. Filter out sub-minimum zones before serialisation: gdf = gdf[gdf.geometry.area >= min_zone_area_m2] (calculate min_zone_area_m2 from swath width × minimum useful zone length).
Missing interior rings. Polygon features with holes (e.g., obstacles, wetlands excluded from application) require CTR elements for each interior ring as well as the exterior. shapely stores interior rings in geometry.interiors; iterate and emit an additional CTR per interior ring with point type A="5" (exclusion boundary).
Rate value overflow. Integer overflow in the PDT value attribute silently wraps to a negative number on some terminals. Assert 0 <= rate * scale_factor <= 2**31 - 1 before serialisation.
Verification & Output Validation
After generating the package, perform these checks before copying to USB:
import zipfile
from lxml import etree
def verify_package(zip_path: str, expected_zone_count: int) -> None:
"""Quick structural verification of a packaged ISOXML archive."""
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
assert "TASKDATA.XML" in names, "TASKDATA.XML missing from archive"
xml_bytes = zf.read("TASKDATA.XML")
# Encoding: must be UTF-8, no BOM
assert not xml_bytes.startswith(b'\xef\xbb\xbf'), \
"UTF-8 BOM detected — will cause parser failure on most terminals"
doc = etree.fromstring(xml_bytes)
tzn_nodes = doc.findall(".//{%s}TZN" % NS)
assert len(tzn_nodes) == expected_zone_count, \
f"Expected {expected_zone_count} TZN elements, found {len(tzn_nodes)}"
# Spot-check coordinate range on first zone
first_lat = float(tzn_nodes[0].find(".//{%s}PNT" % NS).get("C"))
first_lon = float(tzn_nodes[0].find(".//{%s}PNT" % NS).get("D"))
assert -90 <= first_lat <= 90, f"First latitude out of range: {first_lat}"
assert -180 <= first_lon <= 180, f"First longitude out of range: {first_lon}"
print(f"Package verified: {len(tzn_nodes)} zones, first point lat={first_lat:.5f} lon={first_lon:.5f}")
verify_package(OUTPUT_ZIP, expected_zone_count=len(gdf))
Visual spot-check: Load the exported TASKDATA.XML into a GIS (QGIS supports ISOXML via the ISO 11783 plugin) and overlay on a satellite basemap. Zone boundaries should align with the original prescription GeoDataFrame to within a few centimetres. Any systematic offset indicates a coordinate swap or CRS metadata error.
Histogram inspection: Parse the PDV B attribute values back from the XML and compare their distribution against the original RATE_COLUMN values. Median and range should match; large discrepancies indicate a scale-factor error.
Integration with the Broader Pipeline
The ISOXML export step is the final transformation in the yield-to-prescription pipeline. Its upstream dependencies are:
- Spatial interpolation (Spatial Interpolation for Yield Data) — produces the continuous rate surface that feeds either grid-based GRD serialisation or zone classification.
- Management zone classification (Management Zone Classification Algorithms) — produces the polygon GeoDataFrame consumed by
map_zones_to_rates. - Geometry validation (Shapefile Validation for Farm Equipment) — enforces topology rules, CRS consistency, and attribute schema compliance before the data reaches the export layer.
For John Deere Gen 3/4 displays operating in legacy mode (non-AEF ISOXML), a parallel export path targeting the GreenStar XML+Shapefile format is covered in Exporting Prescription Maps to John Deere GreenStar Format. The normalisation and validation functions above are reusable across both export paths; only the serialisation layer changes.
The completed ISOXML archive integrates downstream into fleet telematics platforms (e.g., John Deere Operations Center, CNH AFS Connect) via HTTPS upload APIs that accept the same ZIP structure as USB transfer — enabling cloud-push prescription delivery without physical media.
Frequently Asked Questions
Why do ISOBUS terminals reject my TASKDATA.XML even though it parses in a browser?
Browser XML parsers are lenient about namespace declarations, encoding BOMs, and attribute ordering. ISOBUS firmware parsers are strict: omitting the xmlns declaration, using a UTF-8 BOM, or placing child elements in the wrong sequence causes silent rejection. Validate against the manufacturer-supplied XSD before transfer.
Which coordinate reference system should I use for ISOXML prescriptions?
ISOXML expects WGS84 (EPSG:4326) geographic coordinates for all boundary and grid-point elements. Rate calculations should be performed in the local metric CRS (e.g., the appropriate UTM zone) to preserve area accuracy, then reprojected to EPSG:4326 only at export time.
What is the difference between a grid-based and a zone-based ISOXML prescription?
A grid prescription rasterizes the field into fixed-size cells, each carrying an explicit rate value — suitable for continuous-surface outputs from kriging or IDW interpolation. A zone prescription references polygon boundaries that map to discrete rates — suitable for management-zone outputs from classification algorithms. Check the AEF implementation guide for your target terminal to confirm which approach its task-controller firmware supports.
Related
- Spatial Interpolation for Yield Data — generate the continuous rate surface that feeds grid-based ISOXML prescriptions
- Management Zone Classification Algorithms — produce polygon zones for TZN-based prescriptions
- Shapefile Validation for Farm Equipment — mandatory geometry and schema checks before export
- Exporting Prescription Maps to John Deere GreenStar Format — legacy XML+Shapefile path for Gen 3/4 displays
- Yield Mapping & Variable Rate Prescription Generation — full pipeline context and architectural overview