Reconciling Volume Differences Between Flights
The monthly report says the stockpile lost 640 m³. The site manager says nothing left the yard. Somebody is wrong, and the argument that follows usually runs on assertion rather than evidence, because the two volumes are single numbers with no visible inputs.
There are exactly five things that can make two volumes of an unchanged pile differ, and four of them are processing artefacts. Attributing a difference is a mechanical procedure once the run records described in computing volumes and stockpiles in Python exist, and it takes about two minutes.
The five causes, in the order to check them
The boundary changed. Cheapest to check and the most common cause. If the two runs used different polygons, the comparison is not like-for-like and nothing else needs investigating until it is redone.
The base surface changed. A base re-fitted per survey moves as the pile moves. A prior terrain model that was replaced, or a fixed level that was edited, does the same thing more visibly.
The vertical datum shifted. A change in the geoid model, the PROJ grid availability, or the ground control used will offset one entire survey relative to the other. The signature is unmistakable: the hardstanding around the pile differs by the same amount as the pile.
The reconstruction quality changed. More NoData inside the boundary means less volume counted. Dark wet material, low sun, or a thinner flight plan all produce this, and it always reduces the apparent volume.
Material actually moved. What is left after the other four are excluded.
Figure 1 — Four tests, in cost order. The site conversation is the last step, not the first.
Minimal reproducible solution
Given two run records, the attribution is a comparison over their stored inputs.
import numpy as np
def attribute_difference(run_a: dict, run_b: dict,
hardstanding_diff: np.ndarray,
sigma_total_m3: float) -> dict:
"""Explain a volume difference between two runs, in test order.
run_a / run_b are the volume records: volume, inputs (boundary digest,
base mode and parameters, cell size), and void fraction.
"""
dv = run_b["volume_m3"] - run_a["volume_m3"]
findings = []
if run_a["inputs"]["boundary"]["sha256_16"] != run_b["inputs"]["boundary"]["sha256_16"]:
findings.append("boundary geometry differs — not a like-for-like comparison")
if (run_a["inputs"]["base_mode"] != run_b["inputs"]["base_mode"]
or run_a["inputs"]["base_params"] != run_b["inputs"]["base_params"]):
findings.append("base surface differs between runs")
stable = hardstanding_diff[np.isfinite(hardstanding_diff)]
datum_shift = float(np.median(stable)) if stable.size else float("nan")
if abs(datum_shift) > 0.02:
findings.append(f"surfaces differ by {datum_shift:+.3f} m on stable ground — "
"a datum or alignment offset, not material")
void_delta = run_b["void_fraction"] - run_a["void_fraction"]
if void_delta > 0.03:
findings.append(f"void fraction rose {void_delta:+.1%} — volume is under-counted")
significant = abs(dv) > 2 * sigma_total_m3
return {
"delta_m3": dv,
"significant": bool(significant and not findings),
"artefacts": findings,
"verdict": ("material movement" if significant and not findings
else "within noise" if not significant and not findings
else "processing artefact — resolve before reporting"),
}
The ordering is not cosmetic. Each test is cheaper than the one after it, and each one, if it fires, invalidates everything below — there is no point measuring a datum shift between two runs that used different boundaries.
Figure 3 — Check in this order and most disagreements resolve at step one.
Edge-case matrix
| Observation | Likely cause | Confirming check |
|---|---|---|
| Hardstanding differs by a constant | Vertical datum or control | Median difference on stable ground |
| Hardstanding agrees, pile differs uniformly | Base surface moved | Compare stored base parameters |
| Difference is a clean step month to month | Boundary re-traced | Compare boundary digests |
| Pile “shrinks” in winter | More NoData from wet, dark material | Compare void fractions |
| Paired cut and fill on opposite slopes | Horizontal misalignment | Difference map shows a dipole pattern |
| Gradual drift over many months | Base re-fitted each time as the pile grew | Plot the fitted plane coefficients over time |
| Difference smaller than 2σ | Nothing detectable happened | Report as “no significant change” |
| Only one lobe of a pile changed | Genuine movement | Difference map is localised |
The dipole pattern in the fifth row is worth learning to recognise on sight. A horizontal shift between two surveys produces material apparently removed from one side of every slope and added to the other, summing to nearly zero overall while making the difference map look dramatic. It is an alignment fault, and the fix is in aligning two epochs with ICP before differencing.
Verification snippet
Once the attribution says “material moved”, the claim should be checked against the shape of the change rather than only its total.
import numpy as np
def movement_plausibility(diff: np.ndarray, mask: np.ndarray,
cell: float, limit: float) -> dict:
"""Is the change spatially coherent, or scattered noise that happened to sum?"""
signif = mask & np.isfinite(diff) & (np.abs(diff) > limit)
if not signif.any():
return {"verdict": "no cells exceed the detection limit"}
from scipy import ndimage
labelled, n = ndimage.label(signif)
sizes = ndimage.sum(signif, labelled, range(1, n + 1)) * cell ** 2
largest = float(sizes.max()) if n else 0.0
coherent = largest / (signif.sum() * cell ** 2)
return {
"changed_area_m2": float(signif.sum() * cell ** 2),
"clusters": int(n),
"largest_cluster_m2": largest,
"coherence": float(coherent),
"verdict": ("coherent movement in one area" if coherent > 0.5
else "scattered — check for noise or misalignment"),
}
Real material movement is spatially coherent: a loader works one face, and the change appears as one connected region. A difference spread as thousands of small clusters across the whole pile is almost always a processing artefact that survived the earlier tests, most often a resolution or interpolation change.
Figure 2 — The shape of the change is evidence. A total on its own is not.
Writing the reconciliation into the monthly report
The point of the procedure is to stop these conversations recurring, which means the output belongs in the report rather than in a technician’s head. A useful monthly entry is three lines: the difference, the verdict, and the evidence that supports it.
A difference declared “within noise” should state the detection threshold, because a client reading “no significant change” reasonably wants to know what would have been significant. A difference attributed to an artefact should never be reported as a volume change at all — it should be corrected and the run repeated, with a note that the earlier figure was superseded.
The cumulative series deserves one further discipline. Plot the running total alongside the deliveries and removals the site records independently, and reconcile at the year end rather than every month. Individual months are noisy; a twelve-month series against independent tallies is the strongest evidence a survey programme can produce that its numbers are sound, and it costs nothing beyond keeping the records.
def report_lines(attr: dict, detection_m3: float) -> list[str]:
"""Three lines for the monthly report, from the attribution result."""
lines = [f"Change since previous survey: {attr['delta_m3']:+,.0f} m³"]
if attr["artefacts"]:
lines.append("SUPERSEDED — processing differences found: "
+ "; ".join(attr["artefacts"]))
lines.append("This figure is not comparable and has been recomputed.")
elif attr["significant"]:
lines.append(f"Attributed to material movement (detection limit "
f"±{detection_m3:,.0f} m³).")
lines.append("Boundary, base surface and datum verified unchanged.")
else:
lines.append(f"No significant change: below the ±{detection_m3:,.0f} m³ "
"detection limit for this pile.")
lines.append("Inputs verified identical to the previous survey.")
return lines
When to escalate
- Everything checks out and the site still disagrees. Compare what each side measures. A truck tally counts loose material; a surface integral measures in-place volume, and the bulking factor between them is commonly 15–30 %. Neither figure is wrong.
- The datum shifted and the earlier surveys cannot be recovered. The series has a break in it. Document the break rather than splicing across it, and re-baseline from the corrected survey onward.
- The difference is significant, coherent and unexplained by the site. That is a security or process finding, not a survey one. Hand it over with the difference map rather than trying to settle it in processing.