Filtering Combine Flow Delay and Ramp-Up Errors
One-sentence answer: measure the delay with a clean stop, convert it to a sample count using each pass’s own interval, shift yield and moisture backwards within the pass, then drop the first and last several seconds and anything recorded below walking pace.
Context
Grain cut at the header reaches the mass-flow sensor ten to twenty seconds later, after travelling through the threshing and cleaning system. Until that offset is removed, every yield value is attributed to ground the combine reached afterwards — at 6 km/h and a twelve-second delay, twenty metres down-track. On a field with 30 m management zones, that is most of a zone width, and it displaces every boundary in the same direction, which is why the resulting prescription is confidently applied to the wrong ground.
The end-of-pass artefacts compound it: the seconds while the threshing system fills read low, the seconds while it empties read high, and the pause to unload reads as enormous yield over almost no area. This guide implements the first two stages of yield monitor data cleaning and telemetry QA.
Prerequisites
Beyond the parent topic’s stack: telemetry with mass flow or wet yield, moisture, ground speed and a timestamp, projected to metres and split into passes.
Step-by-step
1. Measure the delay once per machine and crop.
2. Convert to samples using the pass’s own median interval — monitors do not all log at 1 Hz.
3. Shift yield and moisture backwards within each pass.
4. Trim the ramp regions and near-stationary samples.
5. Verify on a headland.
import geopandas as gpd
import numpy as np
import pandas as pd
def measure_flow_delay(stop_test: pd.DataFrame, flow_col: str = "flow_kg_s",
stop_ts: pd.Timestamp = None, floor_frac: float = 0.05) -> float:
"""Seconds of flow after the header leaves the crop, from a clean stop test."""
after = stop_test.loc[stop_test["ts"] >= stop_ts].sort_values("ts")
assert not after.empty, "no samples after the stop timestamp"
steady = float(stop_test.loc[stop_test["ts"] < stop_ts, flow_col].median())
assert steady > 0, "no steady-state flow before the stop — wrong test window"
decayed = after.loc[after[flow_col] <= floor_frac * steady]
assert not decayed.empty, "flow never decayed — extend the recording after the stop"
delay = (decayed["ts"].iloc[0] - stop_ts).total_seconds()
assert 3.0 < delay < 40.0, f"measured delay {delay:.1f}s is implausible — check the stop time"
return float(delay)
def apply_flow_delay(g: gpd.GeoDataFrame, delay_s: float,
value_cols=("yield_wet", "moisture")) -> gpd.GeoDataFrame:
"""Shift measured values backwards within each pass by the flow delay."""
out = []
for pass_id, chunk in g.groupby("pass_id", sort=False):
c = chunk.sort_values("ts").copy()
interval = c["ts"].diff().dt.total_seconds().median()
assert interval and interval > 0, f"pass {pass_id}: cannot determine the sample interval"
shift = int(round(delay_s / interval))
assert shift < len(c) // 2, (
f"pass {pass_id}: delay of {delay_s}s is {shift} samples but the pass is only "
f"{len(c)} — the pass is too short to correct and should be dropped")
for col in value_cols:
c[col] = c[col].shift(-shift)
out.append(c)
res = pd.concat(out).dropna(subset=list(value_cols))
print(f"flow-delay shift removed {1 - len(res) / len(g):.1%} of samples from pass ends")
return gpd.GeoDataFrame(res, geometry="geometry", crs=g.crs)
def trim_ends(g: gpd.GeoDataFrame, ramp_s: float = 8.0,
min_speed_m_s: float = 0.6) -> gpd.GeoDataFrame:
"""Drop the filling and emptying seconds of each pass, and any near-stationary samples."""
keep = []
for _pass_id, chunk in g.groupby("pass_id", sort=False):
c = chunk.sort_values("ts")
t = (c["ts"] - c["ts"].iloc[0]).dt.total_seconds()
span = float(t.iloc[-1])
if span < 4 * ramp_s:
continue # too short to have a usable middle
keep.append(c[(t > ramp_s) & (t < span - ramp_s) & (c["speed_m_s"] > min_speed_m_s)])
out = pd.concat(keep)
removed = 1 - len(out) / len(g)
print(f"end trimming removed {removed:.1%} of samples")
assert removed < 0.40, f"trimming removed {removed:.0%} — passes are too short for these settings"
return gpd.GeoDataFrame(out, geometry="geometry", crs=g.crs)
Inline verification — the headland test, which shows whether the shift went the right way and far enough:
inner = cleaned[cleaned.geometry.within(boundary.buffer(-40))] # field body
edge = cleaned[~cleaned.geometry.within(boundary.buffer(-40))] # headland ring
ratio = float(edge["yield_wet"].median() / inner["yield_wet"].median())
print(f"headland/interior median yield ratio: {ratio:.3f}")
assert 0.80 < ratio < 1.10, (
f"headland yield is {ratio:.2f}× the interior — a ratio well below 0.9 usually means the "
"flow delay is under-corrected and interior grain is still being credited to the headland; "
"above 1.1 suggests over-correction")
Headlands genuinely yield a little less than the field body from compaction and turning, so a ratio around 0.9–0.95 is expected. A ratio of 0.6 is a data artefact, not agronomy.
Settings worth being deliberate about
| Setting | Default here | Why it matters |
|---|---|---|
| Flow delay | measured, ~12 s in maize | Every second of error displaces yield by the machine’s travel in that second — 1.7 m at 6 km/h, and the error is systematic along every pass |
| Decay floor | 5% of steady flow | Defines when the tail has ended in the stop test. A higher floor shortens the measured delay; a lower one waits for sensor noise |
| Ramp trim | 8 s each end | Covers the filling and emptying of the threshing system. Under-trimming leaves a low collar around every pass start |
| Minimum speed | 0.6 m/s | Removes unloading pauses and stationary samples that report yield over almost no area |
| Minimum pass span | 4× the ramp | Below it there is no usable middle after trimming, and the pass should be dropped explicitly rather than silently emptied |
| Shift scope | within a pass | A global shift moves the end of one pass onto the start of the next, often at the opposite end of the field |
Measure the delay per machine and crop each season. It changes with threshing and cleaning settings as well as with the machine, and a fleet-wide default reintroduces most of the error the correction exists to remove.
Gotchas and edge cases
- The delay is machine, crop and settings specific. A small-grain machine clears faster than a large-frame combine in maize, and changing the fan or sieve settings changes the transit time. Measure once per machine-crop combination each season; adopting a single default across a mixed fleet reintroduces the error you are correcting.
-
Shifting across a pass boundary corrupts both passes. Shifting must happen within a pass, which is why pass reconstruction comes first. A global shift over the whole file moves the last samples of one pass onto the first of the next — often at the opposite end of the field.
-
Log intervals vary. Some monitors log at 1 Hz, some at 0.2 Hz, some irregularly under load. Deriving the sample shift from each pass’s own median interval, as above, is what makes the same delay value correct across machines.
-
Short passes cannot be corrected. A point row of forty samples has no steady middle after a twelve-second shift and an eight-second trim at each end. Drop them explicitly and count them rather than letting a negative-length slice produce an empty frame silently.
-
Trimming is not outlier removal. These samples are not extreme values to be filtered statistically — they are measurements of a machine state rather than of a crop. Removing them by percentile would take genuinely high- and low-yielding ground with them.
This guide is part of Yield Monitor Data Cleaning & Telemetry QA — see there for width correction, moisture normalisation and calibration.
Related
- Removing Yield Outliers with Neighborhood Statistics — the filtering stage that follows this one
- Interpolating Sparse Yield Monitor Data with Kriging — why a variogram fitted before this correction models the combine
- Correcting GPS Drift in Yield Monitor Tracks — the positional half of the same problem