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 NN cells grows as N\sqrt{N} rather than as NN. Relative to a volume that grows as NN, 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 NN cells grows as NN. 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 AA the cell area and NN the number of cells inside the boundary:

σV=A(σrandN)2+(σsysN)2\sigma_V = A\sqrt{\left(\sigma_{\text{rand}}\sqrt{N}\right)^2 + \left(\sigma_{\text{sys}}N\right)^2}

For a 2,000 m² pile on a 10 cm grid, N=200,000N = 200{,}000. 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.

How random and systematic surface errors sum across cells Two rows of cells across a pile cross-section. In the top row the per-cell errors alternate in sign and largely cancel when summed, so the total grows as the square root of the cell count. In the bottom row every cell carries the same small positive offset, so the errors reinforce and the total grows linearly with the cell count. Numbers beneath show that over two hundred thousand cells a three centimetre random error contributes one hundred and thirty-four cubic metres while a two centimetre systematic error contributes four thousand. random: signs cancel sum grows as √N — 134 m³ over 200 000 cells systematic: signs reinforce sum grows as N — 4 000 m³ over the same cells A thirty-fold difference from two numbers that look almost the same on a datasheet.

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.

The three terms in a stockpile volume's uncertainty Three contributions are listed with how each scales. Surface error contributes the per-cell height uncertainty multiplied by the footprint area, and it partially cancels across cells only where the errors are independent, which they are not over a correlated surface. Base surface uncertainty contributes wherever the pad beneath the pile was interpolated rather than measured, and it does not cancel at all because it is a systematic offset. Boundary uncertainty contributes the toe ambiguity multiplied by the pile's height at the toe, and it is usually the largest of the three on a shallow pile. surface error per-cell height uncertainty times footprint — cancels only if independent base surface wherever the pad was interpolated, not measured — systematic, no cancellation boundary toe ambiguity times height at the toe — usually the largest on a shallow pile Quoting only the first is how a volume arrives with an uncertainty an order of magnitude too small.

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 N\sqrt{N} 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”.

Simulated volume distributions with and without a shared offset Two overlaid histograms of simulated volumes for the same pile. The first, with only per-cell random noise, is a very narrow peak about one hundred and thirty cubic metres wide. The second, adding a single shared offset drawn per trial, is a broad distribution about four thousand cubic metres wide. Both are centred on the true volume. A note states that the two simulations differ by one line of code, the placement of the offset draw inside or outside the per-cell noise. true volume with shared offset σ ≈ 4 000 m³ random only σ ≈ 134 m³ −8 000 −4 000 0 +4 000 +8 000 m³ deviation from the true volume One line of code separates these two distributions, and one of them is the honest one.

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.

Computing Volumes and Stockpiles in Python