Exporting Prescription Maps to ISOXML for Task Controllers
Problem statement
A prescription is only useful once a task controller can read it, and the controller reads ISO 11783-10 TASKDATA — the ISOXML dialect that ISOBUS terminals load from a memory stick or a telematics push. Serializing to it is where a correct in-memory prescription quietly becomes a wrong file: rates written in the wrong process-data unit, polygons emitted in a projected CRS the terminal interprets as WGS84 degrees, unclosed rings that the controller silently skips, or a treatment zone that references a product the task never declared. The result is not an error dialog. It is a machine that applies the default rate, or nothing, across a zone — and no record that it happened.
This guide serializes the clamped, reconciled zones from the variable-rate prescription pipeline into a valid TASKDATA structure with lxml: a PDT product and PGP product group, a TSK task, and one TZN treatment zone per prescription zone, each carrying a PLN polygon in WGS84 and a PDV process-data value giving the rate. Validation runs before a single element is written — every rate must already sit at or below the regulated maximum (the clamp is the nitrogen prescription module’s job, re-checked here as a serialization guard), every ring must be closed, and every coordinate must be in the degree range WGS84 promises. The same element codes are decoded on the way in by parsing ISOXML and shapefile field boundaries, so a round trip through both modules should reproduce the geometry.
Parameter reference table
Every value below governs whether a terminal accepts the file and applies the intended rate. TASKDATA.XML has no XML namespace, so tags are written bare; ISO 11783-10 mandates WGS84 for PNT coordinates.
| Parameter | Type | Recommended value | Effect on behavior |
|---|---|---|---|
ddi |
int |
6 |
ISO 11783-11 Data Dictionary Identifier for the process variable; 6 is nitrogen application rate as mass/area. A wrong DDI makes the terminal apply the value to the wrong product channel. |
rate_scale |
Decimal |
100 |
Multiplier from kg/ha to the DDI base unit (mg/m²): 1 kg/ha = 100 mg/m². PDV values are integers, so the scale must match the DDI exactly. |
regulated_max_kg_ha |
Decimal |
from registry | Serialization guard; any zone above it aborts the export rather than writing an illegal rate. |
coord_scale |
int |
1 |
ISOXML PNT uses decimal-degree floats directly (attributes C = north, D = east); do not pre-scale to integers as some legacy encoders did. |
default_type |
str |
"2" |
TZN treatment-zone type; type marks the zone as a prescription zone rather than the task default. |
pgp_type |
int |
1 |
PGP product-group type (1 = crop protection / fertilizer allocation) linking the PDT product to the task. |
close_rings |
bool |
True |
Repeats the first PNT as the last so every LSG ring is explicitly closed; terminals skip an unclosed exterior. |
The DDI and its scale are pinned together in a version-stamped export profile, because changing one without the other silently mis-scales every rate a terminal reads.
Runnable implementation
The module below serializes a list of prescription zones (each a rate in kg/ha plus a WGS84 Shapely polygon) into a TASKDATA element tree with lxml, validating before it builds. It targets Python 3.10+, is fully typed, and depends on lxml and shapely.
from __future__ import annotations
import logging
from dataclasses import dataclass
from decimal import Decimal
from lxml import etree
from shapely.geometry import Polygon
logger = logging.getLogger("vra.isoxml")
DDI_N_RATE = 6 # ISO 11783-11 DDI: nitrogen application rate, mass per area
KG_HA_TO_MG_M2 = Decimal("100") # 1 kg/ha = 100 mg/m² (the DDI base unit)
REGULATED_MAX_KG_HA = Decimal("246.0") # serialization guard, from the rule registry
@dataclass(frozen=True)
class RxZone:
zone_id: int
rate_kg_ha: Decimal
polygon: Polygon # WGS84 (EPSG:4326), exterior ring only for brevity
def _validate(zones: list[RxZone]) -> None:
"""Reject anything that would produce a silently-wrong or illegal TASKDATA."""
if not zones:
raise ValueError("no prescription zones to export")
for z in zones:
if z.rate_kg_ha > REGULATED_MAX_KG_HA:
# The clamp upstream should make this impossible; refuse if it slipped.
raise ValueError(f"zone {z.zone_id}: rate {z.rate_kg_ha} exceeds regulated max")
if z.rate_kg_ha < 0:
raise ValueError(f"zone {z.zone_id}: negative rate")
coords = list(z.polygon.exterior.coords)
if len(coords) < 4 or coords[0] != coords[-1]:
raise ValueError(f"zone {z.zone_id}: exterior ring is not closed")
for lon, lat in coords:
if not (-180.0 <= lon <= 180.0 and -90.0 <= lat <= 90.0):
raise ValueError(f"zone {z.zone_id}: coord {lon},{lat} not WGS84 degrees")
def _pln(parent: etree._Element, polygon: Polygon) -> None:
"""Append one ISOXML PLN (exterior boundary) built from the WGS84 ring."""
pln = etree.SubElement(parent, "PLN", A="1") # A=1 => partfield boundary
lsg = etree.SubElement(pln, "LSG", A="1") # A=1 => exterior polygon ring
for lon, lat in polygon.exterior.coords:
# PNT: A=type(1=flag), C=north/latitude, D=east/longitude, as decimal degrees.
etree.SubElement(lsg, "PNT", A="1", C=f"{lat:.8f}", D=f"{lon:.8f}")
def build_taskdata(zones: list[RxZone], task_id: str, product: str) -> etree._Element:
"""Serialize clamped prescription zones into an ISO 11783-10 TASKDATA tree."""
_validate(zones) # fail before any element is emitted
root = etree.Element("ISO11783_TaskData", VersionMajor="4", VersionMinor="0",
ManagementSoftwareManufacturer="crop-planning",
DataTransferOrigin="1")
# Declare the product and product group the treatment zones reference.
etree.SubElement(root, "PDT", A="PDT1", B=product) # B = product name
etree.SubElement(root, "PGP", A="PGP1", B="1") # B = group type
task = etree.SubElement(root, "TSK", A=task_id, B=f"Rx {product}", G="1") # G=1 planned
for z in zones:
# PDV value is an integer in the DDI base unit; scale kg/ha -> mg/m².
value = int((z.rate_kg_ha * KG_HA_TO_MG_M2).to_integral_value())
tzn = etree.SubElement(task, "TZN", A=str(z.zone_id), B=f"zone {z.zone_id}")
pdv = etree.SubElement(tzn, "PDV", A=str(DDI_N_RATE), B=str(value))
pdv.set("C", "PDT1") # C links this value to the declared product
_pln(tzn, z.polygon)
logger.info(
'{"event":"tzn_written","zone_id":%d,"ddi":%d,"value_mg_m2":%d}',
z.zone_id, DDI_N_RATE, value,
)
logger.info('{"event":"taskdata_built","task":"%s","zones":%d}', task_id, len(zones))
return root
def write_taskdata(root: etree._Element, path: str) -> None:
"""Serialize the tree to TASKDATA.XML with a declaration and indentation."""
tree = etree.ElementTree(root)
tree.write(path, xml_declaration=True, encoding="UTF-8", pretty_print=True)
logger.info('{"event":"taskdata_written","path":"%s"}', path)
The validation deliberately runs first and aborts the whole export on any failure, rather than skipping a bad zone: a partial TASKDATA that silently omits a zone is worse than no file, because the operator has no signal that a strip will go untreated. The rate is scaled to the DDI’s integer base unit at write time, so the kilograms-per-hectare the agronomist reasoned about survive as an exact integer the terminal reproduces.
Log patterns and observable signals
Every treatment zone and every file emits structured JSON so an exported TASKDATA can be traced back to the rates and geometry it came from.
Each zone as it is written, with the scaled process-data value:
{"event": "tzn_written", "zone_id": 3, "ddi": 6, "value_mg_m2": 19870}
The completed tree and the file on disk:
{"event": "taskdata_built", "task": "TSK-2026-0417", "zones": 4}
{"event": "taskdata_written", "path": "TASKDATA.XML"}
There is no per-zone log without a matching taskdata_built, so a build that logs zones but never completes means _validate or a geometry step raised — the file was not written, which is the intended fail-closed behavior. Alert on any export attempt that logs tzn_written counts below the input zone count paired with a completion, because that would indicate zones were dropped rather than the export aborting cleanly.
Safe override protocol
Occasionally a terminal in the field needs a file that bends the profile — a legacy controller that expects a different DDI, or a task that reuses an existing product allocation. The override adjusts the export profile; it never disables the rate validation.
Guard conditions, all mandatory:
- Explicit DDI and scale, together. The operator supplies both the DDI and its matching base-unit scale; the module refuses a DDI change without a scale so a rate can never be silently mis-unit.
- Validation still runs. The regulated-max, ring-closure, and WGS84 checks execute unchanged; an override can retarget a product channel but cannot admit an illegal rate or a broken ring.
- Dry-run and re-parse. The overridden file is written to a scratch path and re-read through the boundary parser to confirm the geometry round-trips before it is promoted.
- Immutable audit trail. The original profile, the override DDI and scale, and the approver are written to an append-only ledger, keeping the export consistent with EPA pesticide application recordkeeping.
def build_taskdata_override(
zones: list[RxZone],
task_id: str,
product: str,
ddi: int,
scale: Decimal,
approver: str,
) -> etree._Element:
"""Serialize with a non-default DDI/scale; rate validation is not bypassed."""
_validate(zones) # the same guards run, unconditionally
root = etree.Element("ISO11783_TaskData", VersionMajor="4", VersionMinor="0")
etree.SubElement(root, "PDT", A="PDT1", B=product)
task = etree.SubElement(root, "TSK", A=task_id, B=f"Rx {product}", G="1")
for z in zones:
value = int((z.rate_kg_ha * scale).to_integral_value()) # caller-named scale
tzn = etree.SubElement(task, "TZN", A=str(z.zone_id))
etree.SubElement(tzn, "PDV", A=str(ddi), B=str(value), C="PDT1")
_pln(tzn, z.polygon)
logger.warning(
'{"event":"taskdata_override","task":"%s","ddi":%d,"scale":"%s","approver":"%s"}',
task_id, ddi, scale, approver,
)
return root
Troubleshooting
- Terminal loads the task but applies the default rate on every zone. Root cause: the
PDVDDI does not match the product channel, or the value was scaled with the wrong factor, so the terminal reads zero or an out-of-range setpoint. Remediation: confirm the DDI andrate_scaleare the paired values from the export profile;6with a100scale for a nitrogen mass/area rate. - Zones near a field edge are skipped by the controller. Root cause: the
LSGexterior ring was not closed, so the terminal rejects the polygon. Remediation: keepclose_ringson;_validatealready requirescoords[0] == coords[-1], so this means the polygon reached the exporter open — repair it upstream. - The whole field renders offset or in the ocean on the terminal. Root cause: coordinates were emitted from a projected CRS as if they were degrees, or latitude and longitude were swapped in the
C/Dattributes. Remediation: ensure zones are WGS84 before export (C= latitude/north,D= longitude/east); the degree-range check in_validatecatches most projected inputs. - Export aborts with
rate exceeds regulated max. Root cause: a zone arrived above the label ceiling because the upstream clamp was skipped or a manual edit reintroduced an illegal rate. Remediation: this is the serialization guard doing its job — re-run the prescription pipeline so the clamp fires; do not raiseREGULATED_MAX_KG_HAto force the write. - Round trip through the parser loses a hole or a part. Root cause: this exporter writes exterior rings only for brevity; multipart fields with interior holes need
LSGtype2rings added. Remediation: extend_plnto emit interior rings, mirroring how the boundary parser distinguishesLSGtype1from2.
Frequently asked questions
Why validate rates again at export? Because the export is the last gate before a machine acts, and a manual edit or a skipped clamp can reintroduce an illegal rate. The serialization guard aborts the export if any zone exceeds the regulated maximum, so a wrong rate never reaches a controller.
Why abort instead of skipping a bad zone? A partial TASKDATA that silently omits a zone leaves the operator with no signal that a strip will go untreated. Validation runs before any element is written and raises on the first failure, so the export is all-or-nothing.
How is a kg/ha rate stored in a PDV value? As an integer in the DDI’s base unit — for DDI 6 that is mg/m², and 1 kg/ha equals 100 mg/m², so the rate is multiplied by 100 and rounded. The DDI and scale must match what the decoder expects or the magnitude is wrong.
Related
- Variable-Rate Prescription Maps — the pipeline that produces the clamped, reconciled zones this module serializes.
- Generating Variable-Rate Nitrogen Prescription Maps — the sibling guide that computes the per-zone nitrogen rates written into each
TZN. - Parsing ISOXML and Shapefile Field Boundaries — the decoder for the same
PFD/PLN/LSG/PNTelement codes, for verifying a round trip.
Up: Variable-Rate Prescription Maps · Season Planning & Crop Rotation Optimization