A yield map has impossible values and stripes

On this page

Problem statement

The map shows a flat plateau of identical maximum values, a ring of low yields around the headland, passes that do not match their neighbours and a drift from one end of the day to the other. None of it is agronomy, and each pattern has a specific mechanical cause.

The distinction that matters is between values that are impossible and values that are merely wrong. An impossible value โ€” negative, or several times the crop's physical maximum โ€” is a broken record. A value clipped at the monitor's ceiling is censored: real, high, and flattened. A stripe is a systematic error in a term of the yield calculation, and removing the points does not fix the bias in the rest of the pass.

This page diagnoses a real yield monitor file: 18,444 one-second records from a combine harvesting 6 m of maize at a time in a field near Ferrara, Italy, in 2017, published with the study by Kayad and colleagues on Zenodo.

Quick answer

Diagnose before cleaning, because the pattern names the cause:

print(y["yield_t_ha"].describe(percentiles=[.001, .01, .5, .99, .999]).round(2).to_dict())
print(f"negative or zero: {(y.yield_t_ha <= 0).sum():,}")
print(f"above 3x the median: {(y.yield_t_ha > 3 * y.yield_t_ha.median()).sum():,}")
print(f"exactly at the maximum, {y.yield_t_ha.max()} t/ha: {(y.yield_t_ha == y.yield_t_ha.max()).sum():,}")
print(f"speed below 1 km/h: {(y.speed_kmh < 1).sum():,}; above 12 km/h: {(y.speed_kmh > 12).sum():,}")
print("swath recorded per point:", "swath_m" in y)

# is the variation between passes or within them? (full passes only)
full = y[y["pass_len"] >= 20]
between = full.groupby("pass_id")["yield_t_ha"].mean().var()
within = full.groupby("pass_id")["yield_t_ha"].var().mean()
print(f"between-pass variance {between:.2f}, within-pass {within:.2f}, ratio {between / within:.2f}")
{'count': 18444.0, 'mean': 14.49, 'std': 2.84, 'min': 0.02, '0.1%': 0.17, '1%': 3.03, '50%': 14.97, '99%': 18.0, '99.9%': 18.0, 'max': 18.0}
negative or zero: 0
above 3x the median: 0
exactly at the maximum, 18.0 t/ha: 1,367
speed below 1 km/h: 12; above 12 km/h: 120
swath recorded per point: False
between-pass variance 2.42, within-pass 5.43, ratio 0.45

No record here is impossible in the negative-or-absurd sense. What the file has instead is a ceiling: 1,367 points โ€” 7.4% of the harvest โ€” sit at exactly 18.0 t/ha, because the monitor clips there. The between-to-within ratio says the full passes differ from each other by about half as much as the yield varies along them; whether that is field or machine is what the stripe test below tries to separate. The file records no swath width, so the classic partial-width stripe cannot be checked from this data at all.

Six yield map symptoms and the mechanical cause of each, three of them measured in the Ferrara maize file. read the patternsymptomcausenegative or zero yieldbroken records โ€” remove firsta plateau at exactly 18.0 t/hamonitor ceiling โ€” flag as censoreda low ring: 9.7 t/ha at passstartsfill and empty transients โ€” trimone pass consistently lowpartial width recorded as fulla trend with moisture over the daycheck the moisture correctionbimodal histogramtwo machines, dates or crops inone fileThe Ferrara file had no negatives, 1,367 points at the ceiling and a low ring at pass starts spatialworkflow.io
Six symptoms; each names a different term in the yield calculation.

Impossible values, speed extremes, pass stripes

1. Impossible values first

Negative yield, zero yield with a non-zero flow, and anything above about three times the crop's realistic maximum. These are broken records and they distort every statistic used to find the subtler problems. Just as important is the value that is not impossible but is not real either: a ceiling.

ceiling = y["yield_t_ha"].max()
share = full.groupby("pass_id")["yield_t_ha"].agg(lambda s: (s == ceiling).mean())
print(f"points at {ceiling} t/ha by year:", {yr: int((d.yield_t_ha == d.yield_t_ha.max()).sum()) for yr, d in years.items()})
print(f"share of a full pass at the ceiling: median {share.median():.1%}, worst pass {share.max():.1%}")
points at 18.0 t/ha by year: {2016: 356, 2017: 1367, 2018: 1424}
share of a full pass at the ceiling: median 3.6%, worst pass 41.8%

All three years were clipped at the same 18.0 t/ha, and in the worst pass of 2017 two fifths of the points sit there. They are the high-yielding part of the field with its values flattened, so deleting them biases the map downwards; flag them as censored instead.

2. Very low or very high speed

Yield is mass flow divided by speed times width. At 0.2 km/h the divisor is almost zero and the yield is enormous; above about 12 km/h the machine is usually in transport rather than harvesting. The file records no speed, so it is derived from successive positions โ€” 12 points below 1 km/h and 120 above 12 km/h in 2017.

3. Stripes aligned with the passes

Three causes, distinguishable by their pattern:

pattern cause
one pass consistently low, others normal partial header width recorded as full
passes offset by the moisture reading moisture correction differing between passes
a step at a time of day calibration changed mid-harvest

The moisture reading is in this file, so the second can be looked at:

per_pass = full.groupby("pass_id").agg(yield_t_ha=("yield_t_ha", "mean"), moisture=("moisture_pct", "mean"),
                                       start=("timestamp", "min"))
hourly = y.set_index("timestamp")[["yield_t_ha", "moisture_pct"]].resample("1h").mean().round(1)
hourly.index = [f"hour {i + 1}" for i in range(len(hourly))]
print(hourly.T.to_string())
print(f"pass mean yield vs moisture: r = {per_pass.yield_t_ha.corr(per_pass.moisture):.2f}; "
      f"moisture vs time: r = {per_pass.moisture.corr(per_pass.start.astype('int64')):.2f}")
              hour 1  hour 2  hour 3  hour 4  hour 5  hour 6
yield_t_ha      13.7    13.7    14.1    14.3    15.4    15.6
moisture_pct    18.9    18.8    21.0    20.3    22.6    22.7
pass mean yield vs moisture: r = 0.47; moisture vs time: r = 0.70

Over the six hours of logging โ€” record numbers at one a second โ€” the grain moisture reading rose from about 19% to 23%, and the mapped yield rose with it. That could be the field โ€” the combine worked across it through the day โ€” or it could be a moisture correction applied to a reading that drifted. The data alone cannot say; the pattern is what tells you to go and ask whether the moisture sensor was checked. It is also a warning about rules of thumb: grain moisture is often said to fall through a harvest day, and here it went the other way.

4. A halo around the headland

Start and stop transients, turning, and partial width all concentrate at the ends of passes:

by_position = full.groupby(pd.cut(full["point_in_pass"], [-1, 2, 5, 10, 20, 50, 10_000],
                                  labels=["1-3", "4-6", "7-11", "12-21", "22-51", "52+"]))["yield_t_ha"].median()
print("median t/ha by position in the pass:", by_position.round(1).to_dict())
median t/ha by position in the pass: {'1-3': 9.7, '4-6': 12.0, '7-11': 13.3, '12-21': 14.6, '22-51': 15.3, '52+': 15.0}

The first three seconds of every full pass have a median of 9.7 t/ha against about 15 in the middle โ€” a ring of low values around the field that is the machine filling up, not the crop thinning out. Reconstruct the passes and trim the first and last few points of each.

Median yield by position within each full pass, Ferrara maize 2017. median t/ha by second into the pass, 2017seconds 1โ€“39.7seconds 4โ€“612.0seconds 7โ€“1113.3seconds 12โ€“2114.6seconds 22โ€“5115.3second 52 on15.008 t/ha16 t/haThe first ten or so seconds of each pass read low; those points form the headland ring spatialworkflow.io
The headland halo in numbers, from step 4.

5. Smearing along the direction of travel

The grain takes time to travel from the header to the flow sensor, so each value is recorded some seconds after the crop was cut, which displaces it along the direction of travel โ€” at this field's median 5.7 km/h, each second of lag is about 1.6 m. It shows as a boundary that is blurred in the direction of travel and sharp across it. How to clean yield monitor data estimates and corrects the lag for this same file.

6. Two populations in the histogram

A bimodal distribution usually means two combines, two harvest dates, or two crops in one file. Split before cleaning. The three Ferrara harvests are one crop in one field with similar medians โ€” 14.7, 15.0 and 14.3 t/ha โ€” so stacking them adds points without adding a population.

7. Check the total against the weighbridge

The one external check. A map total that differs from the delivered tonnage by more than about 10% means the calibration, the swath assumption or the moisture basis is wrong โ€” a bias that cleaning will not remove. The published data does not include the delivered tonnage, so the check cannot be run for this field; Example 3 shows it.

A partial-width stripe, a moisture stripe and a calibration step. partial widthone pass, half the valuemoisturealternating passescalibration stepa step in harvest orderThree stripe patterns with three different causes and three different fixes spatialworkflow.io
Three stripe patterns with three different causes and three different fixes.

A diagnostic report on a real yield dataset

The data used on this page

The FIELD_YIELD dataset from Kayad et al. (2019), Zenodo record 14216165 (CC BY 4.0): maize yield monitor points for a 22 ha field at Porto Felloni near Ferrara, 2016โ€“2018, with a record number, position, yield in kg/ha and grain moisture. The monitor logged one point a second, so the record number stands in for time; per the paper, the authors had already removed some border effects. The coordinates are Italian Gaussโ€“Boaga (EPSG:3003) whatever the files' own labels say; the setup derives time, heading, speed and pass numbers the same way as the yield monitor guide.

import os
import zipfile

import geopandas as gpd
import numpy as np
import pandas as pd
import requests

if not os.path.exists("FIELD_YIELD"):
    reply = requests.get("https://zenodo.org/records/14216165/files/FIELD_YIELD.zip?download=1", timeout=300)
    reply.raise_for_status()
    open("FIELD_YIELD.zip", "wb").write(reply.content)
    zipfile.ZipFile("FIELD_YIELD.zip").extractall(".")


def load(path):
    raw = gpd.read_file(path)
    y = gpd.GeoDataFrame(raw.drop(columns="geometry"), geometry=gpd.points_from_xy(raw["EST"], raw["NORD"]),
                         crs="EPSG:3003").sort_values("NUMBER").reset_index(drop=True)
    y["timestamp"] = pd.to_datetime(y["NUMBER"], unit="s")             # record number: one a second
    dx, dy = y["EST"].diff(), y["NORD"].diff()
    y["heading_deg"] = np.degrees(np.arctan2(dx, dy)) % 360
    y["speed_kmh"] = np.hypot(dx, dy) * 3.6 / y["NUMBER"].diff().clip(lower=1)
    y["yield_t_ha"] = y["YIELD"] / 1000
    y["moisture_pct"] = y["HUMID"]
    turn = y["heading_deg"].diff().abs().mod(360)
    y["pass_id"] = ((y["NUMBER"].diff() > 10) | (np.minimum(turn, 360 - turn) > 60)).cumsum()
    y["point_in_pass"] = y.groupby("pass_id").cumcount()
    y["pass_len"] = y.groupby("pass_id")["point_in_pass"].transform("size")
    return y


years = {2016: load("FIELD_YIELD/yield2016/yield2016.gpkg"),
         2017: load("FIELD_YIELD/yield2017/yield2017.shp"),
         2018: load("FIELD_YIELD/yield2018/yield2018.shp")}
y = years[2017]
print({yr: f"{len(d):,} points, median {d['yield_t_ha'].median():.1f} t/ha" for yr, d in years.items()})
print(f"2017: {y['pass_id'].nunique()} passes, {y.loc[y['pass_len'] >= 20, 'pass_id'].nunique()} of them 20 s or longer; "
      f"median speed {y['speed_kmh'].median():.1f} km/h; {(y['timestamp'].max() - y['timestamp'].min())} of logging")
{2016: '21,169 points, median 14.7 t/ha', 2017: '18,444 points, median 15.0 t/ha', 2018: '22,616 points, median 14.3 t/ha'}
2017: 144 passes, 59 of them 20 s or longer; median speed 5.7 km/h; 0 days 05:44:00 of logging

Example 1 โ€” the diagnostic report

def diagnose_yield(y, crop_max=20.0, min_pass=20):
    v = y["yield_t_ha"]
    r = {"points": len(y),
         "negative_or_zero": int((v <= 0).sum()),
         "above_crop_max": int((v > crop_max).sum()),
         "at_the_maximum": int((v == v.max()).sum()),
         "maximum": float(v.max()),
         "median": round(float(v.median()), 2)}
    if "speed_kmh" in y:
        r["speed_under_1"] = int((y.speed_kmh < 1).sum())
        r["speed_over_12"] = int((y.speed_kmh > 12).sum())
    if "swath_m" in y:
        r["partial_swath_share"] = round(float((y.swath_m < 0.95 * y.swath_m.max()).mean()), 3)
    if "pass_id" in y:
        full = y[y.groupby("pass_id")["pass_id"].transform("size") >= min_pass]
        between = float(full.groupby("pass_id")[v.name].mean().var())
        within = float(full.groupby("pass_id")[v.name].var().mean())
        r["between_within_ratio"] = round(between / within, 3) if within else None
        if "moisture_pct" in y:
            m = full.groupby("pass_id")["moisture_pct"].mean()
            r["moisture_range_between_passes"] = round(float(m.max() - m.min()), 2)
    if "timestamp" in y:
        hourly = y.set_index("timestamp")[v.name].resample("1h").mean()
        r["largest_hourly_step"] = round(float(hourly.diff().abs().max()), 2)
    return r


for year, data in years.items():
    print(year, diagnose_yield(data))
2016 {'points': 21169, 'negative_or_zero': 0, 'above_crop_max': 0, 'at_the_maximum': 356, 'maximum': 18.0, 'median': 14.66, 'speed_under_1': 22, 'speed_over_12': 95, 'between_within_ratio': 0.455, 'moisture_range_between_passes': 4.69, 'largest_hourly_step': 1.47}
2017 {'points': 18444, 'negative_or_zero': 0, 'above_crop_max': 0, 'at_the_maximum': 1367, 'maximum': 18.0, 'median': 14.97, 'speed_under_1': 12, 'speed_over_12': 120, 'between_within_ratio': 0.447, 'moisture_range_between_passes': 6.05, 'largest_hourly_step': 1.09}
2018 {'points': 22616, 'negative_or_zero': 0, 'above_crop_max': 0, 'at_the_maximum': 1424, 'maximum': 18.0, 'median': 14.32, 'speed_under_1': 24, 'speed_over_12': 96, 'between_within_ratio': 0.483, 'moisture_range_between_passes': 3.1, 'largest_hourly_step': 1.63}

The same report for three harvests of one field shows what is the machine and what is the season: the 18.0 t/ha ceiling is there every year, and the speed and moisture figures move from year to year. With a 20 t/ha crop maximum the ceiling never registers as "above the maximum" โ€” only the count of points at the exact maximum finds it.

The diagnostic report and stripe test for three harvests of the same Ferrara field. one field, three harvestsmeasure201620172018points at 18.0 t/ha3561,3671,424speed over 12 km/h9512096moisture range, passes4.696.053.10between/within ratio0.4550.4470.483lag-1, harvest order0.3700.2780.502lag-1, across the field-0.0250.0720.135The ceiling repeats every year: that is the machine, not the season spatialworkflow.io
Example 1 and Example 2 for all three years of the Ferrara field.

Example 2 โ€” separate a stripe from a real pattern

def stripe_test(y, value="yield_t_ha", pass_col="pass_id", min_pass=20):
    """How much do neighbouring passes' means agree, in harvest order and across the field?"""
    full = y[y.groupby(pass_col)[pass_col].transform("size") >= min_pass]
    g = full.groupby(pass_col)
    means = g[value].mean()
    heading = np.radians(full["heading_deg"].median())
    across = g["EST"].mean() * np.cos(heading) - g["NORD"].mean() * np.sin(heading)
    result = {"passes": len(means), "between_pass_sd": round(float(means.std()), 2),
              "lag1_harvest_order": round(float(means.loc[g["timestamp"].min().sort_values().index].autocorr(1)), 3),
              "lag1_across_field": round(float(means.loc[across.sort_values().index].autocorr(1)), 3)}
    print(result)
    return result


for year, data in years.items():
    print(year, end=" ")
    stripe_test(data)
2016 {'passes': 61, 'between_pass_sd': 1.31, 'lag1_harvest_order': 0.37, 'lag1_across_field': -0.025}
2017 {'passes': 59, 'between_pass_sd': 1.56, 'lag1_harvest_order': 0.278, 'lag1_across_field': 0.072}
2018 {'passes': 69, 'between_pass_sd': 1.85, 'lag1_harvest_order': 0.502, 'lag1_across_field': 0.135}

A real field pattern varies smoothly across adjacent passes, so the pass means are autocorrelated when the passes are put in order across the field; a machine artefact is independent from pass to pass. Here the correlation of neighbouring passes across the field is weak in every year, and stronger in harvest order โ€” which is what a drift through the day, like the moisture one above, would produce. Neither number is a verdict on its own: a combine that harvests in lands rather than back and forth breaks the link between harvest order and position, and a pass that crosses two soil types is an average of both. Treat the test as a prompt to look at the map, not as a classifier.

Example 3 โ€” the weighbridge check

def weighbridge_check(y, delivered_t, area_ha, value="yield_t_ha"):
    mapped = float(y[value].mean() * area_ha)
    factor = delivered_t / mapped
    verdict = ("good" if 0.95 <= factor <= 1.05
               else "acceptable" if 0.90 <= factor <= 1.10
               else "the calibration, swath or moisture basis is wrong")
    print(f"map {mapped:.1f} t, delivered {delivered_t:.1f} t, factor {factor:.3f} โ€” {verdict}")
    return factor

weighbridge_check(y, delivered_t=..., area_ha=...)      # the tonnage is not in the published data

A factor well away from 1 is not a scaling problem to be divided away; it means the machine was measuring something other than what was delivered, and the same error will recur next year unless the calibration is fixed.

Why a ceiling is worse than an outlier

Why a ceiling is worse than an outlier

An outlier is one bad point among good ones, and any robust filter finds it. A ceiling changes every high value in the same way: the points at 18.0 t/ha are real harvest, but their true values are anywhere above that. A mean over a field with 7% of its points clipped is biased low, a map of the best areas is flat-topped, and no filter can recover the lost values. The honest record says how many points are censored.

Distinguishing a machine stripe from a real field pattern. stripe or field?a machine artefactโœ•neighbours across the field disagreeโœ•low lag-1 autocorrelationโœ•repeats every year, same machineโœ•boundaries parallel to travela real patternโœ“pass means vary smoothlyโœ“high lag-1 autocorrelationโœ“matches terrain or soilโœ“boundaries cross the travel directionPut the passes in order across the field: neighbours that agree are the field spatialworkflow.io
A real pattern varies smoothly between adjacent passes; an artefact does not.

Why partial width produces a one-sided stripe

The monitor divides measured mass by an assumed area. On a half-width pass the true area is half the assumed one, so the recorded yield is half the true value. That is a 50% error on one pass โ€” far larger than any sensor error โ€” and it appears as a single consistently low stripe rather than as scattered noise, because it affects the whole pass. A file that does not record the working width, like this one, cannot show it except as a pass that is low end to end.

Yield is reported at a standard moisture. If the correction differs between passes โ€” a drifting sensor, or a manual entry changed part way through the day โ€” each pass carries a different multiplicative offset. Since passes are harvested in order, a drift appears as a trend with the harvest sequence rather than with the field.

Why the headland halo is unavoidable without trimming

At the end of every pass the machine slows, stops, turns and restarts. Flow decays and rebuilds over several seconds, the swath is partial on the turn, and the position is changing quickly. All of the affected points are at the ends of passes, so they form a ring. No statistical filter can distinguish them from real headland variation; only the pass geometry can.

Why the weighbridge check cannot be skipped

Calibration error, a wrong swath setting and a wrong moisture basis all scale the entire map. Nothing internal to the map reveals them, because every internal statistic is affected equally. The delivered tonnage is the only independent measurement in the whole system.

Crop maxima and zero-flow points

  • Split by date and machine first. A bimodal histogram is usually two of something.
  • Crop maximum is crop-specific. 20 t/ha is absurd for wheat and low for sugar beet.
  • Zero yield with zero flow is correct โ€” the machine was not harvesting.
  • Real headland effects exist. Trimming removes the artefact and the signal together.
  • Moisture basis must be stated. Dry-matter and standard-moisture yields differ by 15%.
  • A stripe that repeats every year is the machine, not the field.
  • Keep the removed points as evidence.
  • Fix the calibration rather than scaling the map, or it recurs.

FAQ

Why does my yield map have negative values?

Broken records โ€” a sensor fault, a zero-speed division, or a calibration with a negative intercept. Remove them before computing anything.

What causes stripes down a yield map?

Partial header width recorded as full (one low stripe), an inconsistent moisture correction (alternating stripes), or a calibration change mid-harvest (a step).

How do I tell a stripe from a real field pattern?

Check whether the pass means are autocorrelated in spatial order. A real pattern varies smoothly between adjacent passes; an artefact does not.

Why is the headland always brighter or darker?

Start and stop transients, turning and partial width all concentrate there. Trim the first and last few points of each pass.

My map total is 25% above the weighbridge. Should I scale it?

No. That size of error means the calibration, the swath setting or the moisture basis is wrong, and scaling hides a problem that will recur.

Why is my histogram bimodal?

Usually two combines, two harvest dates or two crops in one file. Split before cleaning.