Estimating Volume Uncertainty from Surface Error
A report lands on a quantity surveyor’s desk: 18,472 m³, accuracy ±3 cm. The second figure is a surface accuracy and the first is a volume, and nothing in the document connects them. Asked what the volume’s uncertainty is, most pipelines cannot answer, and the ones that try usually multiply 3 cm by the footprint area and produce a number that is far too large — or divide by the square root of the cell count and produce one that is far too small.
Both answers are halves of the correct one. Surface error has two components that propagate into a volume in completely different ways, and quoting either alone is what makes volume uncertainty a subject of argument rather than arithmetic.
Two errors, two propagation laws
Random error is the cell-to-cell scatter of the surface around its own local mean: reconstruction noise, interpolation wobble, the residual of a gridding operation. It is close to independent between cells a few metres apart, so summing it over cells grows as rather than as . Relative to a volume that grows as , the random contribution therefore shrinks as the pile gets bigger.
Systematic error is a uniform offset of the whole surface: a vertical datum mismatch, a camera calibration bias, a base surface that is consistently low. It is perfectly correlated between cells, so summing it over cells grows as . Relative to the volume, it is constant — a 2 cm bias is always about 2 cm divided by the mean pile height, whatever the footprint.
Written out, with the cell area and the number of cells inside the boundary:
For a 2,000 m² pile on a 10 cm grid, . A 3 cm random error contributes about 134 m³; a 2 cm systematic error contributes 4,000 m³. They are not comparable quantities, and the second one is the answer.
Figure 1 — The same two centimetres, propagated two ways. This picture is the whole argument.
Minimal reproducible solution
Both terms have to be measured, not assumed, and both come out of the same checkpoint residuals that a project already collects for checkpoint-based accuracy validation.
import numpy as np
def decompose_surface_error(residuals: np.ndarray) -> dict:
"""Split checkpoint residuals into a systematic bias and random scatter.
The mean is the systematic component: a surface offset every cell shares.
The scatter about that mean is the random component. Using the RMSE for
both — as most reports do — double-counts the bias and still propagates
it with the wrong law.
"""
r = residuals[np.isfinite(residuals)]
if r.size < 8:
raise ValueError(f"only {r.size} checkpoints — not enough to separate the terms")
bias = float(np.mean(r))
scatter = float(np.std(r, ddof=1))
# Standard error on the bias itself: with few checkpoints, the bias we
# measured is uncertain, and that uncertainty is itself systematic.
bias_se = scatter / np.sqrt(r.size)
return {
"sigma_systematic_m": float(np.hypot(abs(bias), bias_se)),
"sigma_random_m": scatter,
"measured_bias_m": bias,
"n_checkpoints": int(r.size),
"rmse_m": float(np.sqrt(np.mean(r ** 2))),
}
def volume_uncertainty(n_cells: int, cell_size: float, err: dict) -> dict:
"""Propagate the two components into a volume uncertainty."""
area = cell_size ** 2
rand = err["sigma_random_m"] * np.sqrt(n_cells) * area
syst = err["sigma_systematic_m"] * n_cells * area
return {
"sigma_random_m3": float(rand),
"sigma_systematic_m3": float(syst),
"sigma_total_m3": float(np.hypot(rand, syst)),
"dominant": "systematic" if syst > rand else "random",
}
Folding the standard error of the bias into the systematic term is the subtle part. With twelve checkpoints and a 3 cm scatter, the measured bias is itself uncertain by about 0.9 cm, and that uncertainty behaves systematically — it is a single unknown offset shared by every cell. Treating it as random would understate the result.
Figure 3 — Three terms, and the one most often quoted alone is rarely the biggest.
Edge-case matrix
| Situation | Naive handling | Correct handling |
|---|---|---|
| Checkpoints only on hardstanding | Bias measured on one surface type | Sample the pile material too, or state the limitation |
| Fewer than 8 checkpoints | Bias estimated but unstable | Fold the standard error into the systematic term |
| Bias is zero within noise | Systematic term dropped entirely | Keep the standard error term; it never vanishes |
| Two epochs differenced | Errors added | Common bias cancels; only the difference propagates |
| Grid finer than point spacing | N inflated, random term overstated |
Use effective independent cells, not raw cell count |
| Correlated random error | Treated as independent | Estimate a correlation length; divide N by cells per correlation area |
| Base from a different survey | Base error ignored | Add the base’s own uncertainty in quadrature |
| Client quotes a percentage tolerance | Compared against RMSE | Compare against sigma_total / volume |
The grid-resolution row is the one that flatters results. Gridding a 5 cm-spacing cloud at 2 cm produces four times the cells, and a naive shrinks the random term by a factor of two — purely from interpolation, with no new information. Use the point spacing to compute an effective cell count:
def effective_cells(n_cells: int, cell_size: float, point_spacing: float) -> int:
"""Independent cells, given that finer gridding does not add information."""
if cell_size >= point_spacing:
return n_cells
ratio = (point_spacing / cell_size) ** 2
return max(int(n_cells / ratio), 1)
Verification snippet
The propagation can be checked directly by simulation, which is worth doing once because the result is counter-intuitive enough that people reject it.
import numpy as np
def simulate_volume_uncertainty(true_surface: np.ndarray, mask: np.ndarray,
cell: float, sigma_rand: float,
sigma_sys: float, trials: int = 400) -> dict:
"""Monte-Carlo check that the analytic propagation is right."""
rng = np.random.default_rng(0)
truth = float(np.nansum(np.where(mask, true_surface, np.nan)) * cell ** 2)
vols = np.empty(trials)
for t in range(trials):
noise = rng.normal(0.0, sigma_rand, size=true_surface.shape)
offset = rng.normal(0.0, sigma_sys) # ONE draw for the whole surface
perturbed = true_surface + noise + offset
vols[t] = np.nansum(np.where(mask, perturbed, np.nan)) * cell ** 2
n = int(np.count_nonzero(mask))
analytic = np.hypot(sigma_rand * np.sqrt(n), sigma_sys * n) * cell ** 2
return {"truth_m3": truth, "simulated_sigma_m3": float(vols.std(ddof=1)),
"analytic_sigma_m3": float(analytic),
"ratio": float(vols.std(ddof=1) / analytic)}
The single offset draw per trial, outside the per-cell loop, is what makes the simulation correct — and seeing that one line is usually what convinces a sceptical reviewer, because it is the concrete expression of “every cell shares this error”.
Figure 2 — The simulation that settles the argument. Both histograms are centred correctly; only one is the right width.
Reporting the number so it survives review
A defensible volume report carries four fields, not one: the volume, the total uncertainty, the split between its components, and the number of checkpoints the components were measured from. The split matters because it tells the reader what would improve the result — more checkpoints and better datum control if systematic dominates, a denser reconstruction if random does.
Round the volume to the precision the uncertainty supports. Quoting 18,472 m³ ± 4,000 m³ is arithmetically consistent and rhetorically absurd; 18,500 m³ ± 4,000 m³ says the same thing without inviting the reader to believe the last two digits. A useful rule is to round to roughly a tenth of the stated uncertainty.
State the confidence level explicitly. A one-sigma figure covers about 68 % of outcomes and most readers will assume it means something closer to certainty. If the contract language is “within X percent”, that is almost certainly a 95 % expectation, which is roughly two sigma — so the pipeline should compare 2 * sigma_total / volume against the tolerance, not sigma_total / volume.
def format_volume(volume: float, sigma: float, *, confidence: float = 1.96) -> str:
"""A volume string that does not overstate its own precision."""
half_width = confidence * sigma
step = 10 ** max(int(np.floor(np.log10(max(half_width / 10, 1e-9)))), 0)
v = round(volume / step) * step
u = round(half_width / step) * step
return f"{v:,.0f} m³ ± {u:,.0f} m³ (95 %)"
Finally, keep the uncertainty attached to the volume in the same record rather than in the covering email. The volume record described in the topic page exists for exactly this reason: an uncertainty that travels separately from its measurement is one that will be lost by the second forwarded message.
When to escalate
- The systematic term exceeds the client’s whole tolerance. No processing change fixes this. It is a survey-control problem, and the fix is more and better-distributed ground control — see setting accuracy thresholds for survey projects.
- There are no checkpoints at all. Then there is no measured uncertainty, and any figure quoted is a guess. Say that plainly rather than substituting a datasheet number.
- The client compares against a truck count. A surface integral and a load tally measure different things — bulking, compaction and moisture all sit between them. Reconcile the methods before reconciling the numbers.