Exporting Prescription Maps to John Deere GreenStar Format

TL;DR: Call gdf.to_crs("EPSG:4326"), write the shapefile with gdf.to_file(..., driver="ESRI Shapefile"), then generate a Prescription.xml descriptor with xml.etree.ElementTree. Copy all five sidecar files to a FAT32 USB drive and import via Prescriptions > Import on the GreenStar display.


Why this export path exists

John Deere GreenStar Gen 3 and Gen 4 displays can accept prescriptions two ways: as a standards-compliant ISOXML package (the cross-manufacturer route) or as the proprietary shapefile-plus-XML bundle described here. In practice, many older Gen 3 installs and field technicians still prefer the direct USB import because it requires no telematics account, no Operations Center sync, and no firmware above a baseline version.

The risk of skipping either component is concrete. Without the Prescription.xml descriptor the display can render zone boundaries on the field map but it cannot resolve which product to apply or at what rate — the implement controller receives no target signal. Without correct WGS84 geometry, zone boundaries shift by tens of metres relative to the RTK GPS track, causing under- or over-application at zone transitions.

Before your prescription geometry reaches this export step, your shapefile validation for farm equipment pass should already have resolved topology errors and confirmed that the attribute schema is intact.


GreenStar export bundle — what goes on the USB

The diagram below shows which files the export produces and how the GreenStar display parses them at import time.

GreenStar Export Bundle Diagram showing the five shapefile sidecar files plus Prescription.xml that form the USB bundle, and how the GreenStar display parses them: it reads Prescription.xml first to resolve product and unit, then loads the .shp geometry, then queries the .dbf attribute table to map zone IDs to rates. FAT32 USB drive Prescription.xml Prescription.shp Prescription.shx Prescription.dbf Prescription.prj GreenStar display (Gen 3 / 4) 1. Parse Prescription.xml resolve product, unit, rate table 2. Load .shp geometry render zone polygons on field map 3. Query .dbf attributes match ZoneID → rate per polygon 4. Send target rate to controller ISOBUS section control signal

Prerequisites

These requirements are additive to the parent cluster’s environment. If you already have geopandas installed for your variable-rate export to ISOXML workflow, only the Python version check is new.

  • Python 3.9+xml.etree.ElementTree.indent (used for readable XML output) was added in 3.9
  • geopandas >= 0.12, pandas >= 1.5, fiona >= 1.9, pyproj >= 3.4
  • shapely >= 2.0 (for make_valid geometry repair)
  • A prescription GeoDataFrame with a numeric rate column (e.g. Rate_kgha) and, optionally, a ZoneID column
BASH
pip install "geopandas>=0.12" "pandas>=1.5" "shapely>=2.0" "pyproj>=3.4"

Step-by-step

1. Validate geometry and reproject to EPSG:4326

GreenStar Gen 3 and Gen 4 displays expect WGS84 decimal-degree coordinates. Any other CRS causes zone boundaries to shift relative to the tractor’s GPS track. If your prescription originates from spatial interpolation for yield data in a local UTM projection, reproject it here before touching the shapefile writer.

PYTHON
import warnings
import geopandas as gpd
import pandas as pd
import xml.etree.ElementTree as ET
from pathlib import Path
from shapely.validation import make_valid

def export_greenstar(
    gdf: gpd.GeoDataFrame,
    output_dir: str | Path,
    base_name: str = "Prescription",
    product_name: str = "Nitrogen",
    rate_unit: str = "kg/ha",
    rate_col: str = "Rate_kgha",
    zone_id_col: str | None = "ZoneID",
) -> Path:
    """Export prescription GeoDataFrame to John Deere GreenStar format.

    Returns the output directory Path for downstream verification.
    """
    out = Path(output_dir)
    out.mkdir(parents=True, exist_ok=True)

    # --- Step 1: Validate geometry ----------------------------------------
    if gdf.empty:
        raise ValueError("GeoDataFrame is empty — nothing to export.")

    gdf = gdf.copy()
    gdf["geometry"] = gdf["geometry"].apply(make_valid)
    gdf = gdf[~gdf["geometry"].is_empty].reset_index(drop=True)

    if len(gdf) == 0:
        raise ValueError("All geometries were empty after make_valid repair.")

    # --- Step 2: Reproject to WGS84 (EPSG:4326) ---------------------------
    if gdf.crs is None:
        warnings.warn("No CRS set — assuming EPSG:4326. Set crs explicitly to suppress.")
        gdf = gdf.set_crs("EPSG:4326")
    elif gdf.crs.to_epsg() != 4326:
        gdf = gdf.to_crs("EPSG:4326")

    # Coordinate bounds sanity check
    min_lon, min_lat, max_lon, max_lat = gdf.total_bounds
    assert -180 <= min_lon and max_lon <= 180, "Longitude out of WGS84 range"
    assert -90 <= min_lat and max_lat <= 90, "Latitude out of WGS84 range"

    # --- Step 3: Sanitize rate column -------------------------------------
    if rate_col not in gdf.columns:
        raise KeyError(f"Rate column '{rate_col}' not found. Available: {list(gdf.columns)}")

    gdf[rate_col] = pd.to_numeric(gdf[rate_col], errors="coerce").fillna(0.0)

    # Warn about zero-rate zones (common after NaN fill — worth agronomic review)
    zero_zones = (gdf[rate_col] == 0.0).sum()
    if zero_zones > 0:
        warnings.warn(f"{zero_zones} zone(s) have a zero rate — verify these are intentional.")

    # --- Step 4: Write shapefile bundle (.shp/.shx/.dbf/.prj) ------------
    shp_path = out / f"{base_name}.shp"
    gdf.to_file(shp_path, driver="ESRI Shapefile")

    # --- Step 5: Build Prescription.xml -----------------------------------
    root = ET.Element("PrescriptionMap", version="1.0", encoding="UTF-8")

    ET.SubElement(root, "Product", id="P001", name=product_name, unit=rate_unit)

    rate_table = ET.SubElement(
        root, "RateTable", productId="P001", sourceFile=f"{base_name}.shp"
    )

    for i, row in gdf.iterrows():
        # Use ZoneID column when present; fall back to row index
        if zone_id_col and zone_id_col in gdf.columns:
            zone_id = str(row[zone_id_col])
        else:
            zone_id = str(i)

        ET.SubElement(
            rate_table,
            "RateEntry",
            zoneId=zone_id,
            rate=f"{row[rate_col]:.4f}",
        )

    ET.indent(root, space="  ")
    xml_path = out / "Prescription.xml"
    ET.ElementTree(root).write(xml_path, encoding="utf-8", xml_declaration=True)

    return out

2. Verify the output before copying to USB

Run this inline check immediately after the function returns. Catching problems at this stage is far cheaper than a failed display import in the field.

PYTHON
# Quick verification — run before packaging to USB
out_path = export_greenstar(
    gdf=prescription_gdf,
    output_dir="/tmp/greenstar_export",
    base_name="Nitrogen_Field7",
    product_name="Urea",
    rate_unit="kg/ha",
    rate_col="N_rate_kgha",
)

# Assert all five required files exist
required = ["Nitrogen_Field7.shp", "Nitrogen_Field7.shx",
            "Nitrogen_Field7.dbf", "Nitrogen_Field7.prj", "Prescription.xml"]
for fname in required:
    assert (out_path / fname).exists(), f"Missing export file: {fname}"

# Spot-check: re-read the shapefile and verify row count and CRS
check_gdf = gpd.read_file(out_path / "Nitrogen_Field7.shp")
assert check_gdf.crs.to_epsg() == 4326, "CRS mismatch in exported shapefile"
assert len(check_gdf) == len(prescription_gdf), "Row count changed during export"
print(f"Export OK — {len(check_gdf)} zones, CRS EPSG:4326, files in {out_path}")

3. Deploy to USB and import on the display

  1. Format the USB drive as FAT32 (not exFAT — most Gen 3 firmware will not mount exFAT).
  2. Copy all five files to the root of the USB drive (or a \GREENSTAR\PRESCRIPTIONS\ subdirectory if your firmware version requires it).
  3. On the GreenStar 3 or 4 display: Menu → Prescriptions → Import → USB.
  4. The display resolves Prescription.xml first. If it reports “Product not found”, the product_name in the XML does not match a product defined in the display’s product catalogue — add the product manually before importing.
  5. Before engaging the implement controller, run one pass in Simulate mode and confirm that rate read-outs at zone transitions match your intended application table.

Gotchas and edge cases

  • Missing .prj file causes silent CRS rejection. geopandas.to_file always writes a .prj sidecar when the GeoDataFrame has a CRS set. If you see no .prj in the output, gdf.crs was None at write time — set it explicitly before calling to_file.

  • Rate values truncated at two decimal places in the display. GreenStar Gen 3 firmware rounds displayed rates to two decimal places internally, so storing four decimal places in the XML (as the code above does) has no negative effect but ensures you retain precision for any post-operation record reconciliation.

  • ZoneID must match the .dbf attribute row index. The <RateEntry zoneId="..."> attribute in Prescription.xml is cross-referenced against the .dbf row when the display builds its rate lookup table. If you manipulate the GeoDataFrame’s index between the shapefile write and the XML write, the IDs drift. The safest pattern is to call reset_index(drop=True) once at the start and derive zone_id from enumerate or the reset integer index throughout.

  • Non-polygon geometries are silently ignored. GreenStar prescription imports reject point and line geometries. If your GeoDataFrame contains mixed geometry types (e.g. centroid points left over from a management zone classification step), filter to polygons with gdf = gdf[gdf.geometry.geom_type.isin(["Polygon", "MultiPolygon"])] before calling export_greenstar.

  • UTF-8 BOM breaks the XML parser on older firmware. ET.ElementTree.write(..., encoding="utf-8") without xml_declaration=True will omit the BOM. If you manually construct the XML string and write it yourself, avoid encoding="utf-8-sig".


FAQ

Why does GreenStar reject my shapefile even though the geometry looks correct in QGIS?

The two most common causes are a non-WGS84 projection (QGIS reprojects on-the-fly for display, masking the mismatch) and a missing .prj sidecar. Check gdf.crs.to_epsg() equals 4326 and confirm all five sidecar files are present on the USB.

Can I use the same GeoDataFrame for both GreenStar and ISOXML exports?

Yes. The normalization steps — to_crs("EPSG:4326"), make_valid, numeric rate sanitization — are identical. The variable-rate export to ISOXML workflow continues from the same clean GeoDataFrame; only the serialization layer differs.

What does Prescription.xml do — can I skip it and load the shapefile directly?

No. The shapefile carries geometry and attribute data, but the GreenStar display needs Prescription.xml to resolve the product name, unit string, and the zone-ID-to-rate mapping. Without it the display renders the polygons but the implement controller receives no application-rate signal.


This guide is part of Variable Rate Export to ISOXML — see there for the full pipeline context, including CRS normalization, topology repair, and ISOXML schema validation steps that precede this export.