Volumes and earthworks

River Architect reports cut and fill quantities as a project deliverable, so how the volume under a surface is integrated is a substantive choice, not an implementation detail.

Two ways to integrate, and why they differ

Cell-prism sum. Treat every cell as a vertical prism: volume = sum(z) * cell_area. Simple, and wrong at the edges. A raster surface is a set of samples at cell centres; the prism model implicitly extends each boundary cell by half a cell in every direction, so the result carries an error proportional to the perimeter of the surface.

Triangulated surface (TIN). Build a triangulated surface through the cell centres and integrate under it. Every 2x2 block of neighbouring centres forms a quad split into two triangles; over each triangle the elevation varies linearly and the volume against a reference plane has a closed form, with clipping where the surface crosses the plane.

The difference is not small. For a flat surface at z = 5 over an 11x11 grid of 1 m cells:

Method

Volume

Comment

TIN

500.0 m³

the TIN spans 10 x 10 m, from centre to centre

cell-prism sum

605.0 m³

21 % high; counts a half-cell skirt all the way round

For surfaces that are truncated at their edge, that error does not vanish under grid refinement, because the perimeter term scales with the boundary, not the cell size.

arcpy.SurfaceVolume_3d used the triangulated method. riverarchitect.volume does the same, so quantities stay comparable with results produced by the original software.

Usage

import numpy as np
from riverarchitect.volume import surface_volume

z = np.full((11, 11), 5.0)
result = surface_volume(z, dx=1.0, dy=1.0, plane=0.0, reference="ABOVE")

result["volume"]   # 500.0  cubic map units
result["area_2d"]  # 100.0  planimetric area contributing to the volume
result["area_3d"]  # 100.0  draped (true surface) area

reference="BELOW" integrates the part of the surface below the plane. NoData cells (numpy.nan) drop the triangles that touch them, matching arcpy’s behaviour of building the TIN only where data exists.

The draped area area_3d is genuinely useful for costing surface treatments such as seeding or revetment, where the quantity follows the real surface rather than its map footprint. The original code computed it and threw it away.

Earthworks between two DEMs

riverarchitect.volume_assessment wraps the above into the usual workflow:

from riverarchitect.volume_assessment import VolumeAssessment

va = VolumeAssessment("dem.tif", "dem_modified.tif", unit="us",
                      level_of_detection=0.99)
result = va.run(output_dir="Output/volumes")

Three things it does that are worth knowing about:

Explicit alignment. If the two DEMs do not share a grid, the modified DEM is resampled onto the original’s grid before differencing, and the fact is logged. The original relied on arcpy.env.extent to reconcile this silently, which is the most likely way to get quietly wrong numbers when the post-project survey covers a different footprint.

Level of detection. Elevation changes smaller than this threshold are treated as survey noise and zeroed before integration. Defaults to 0.99 ft (unit="us") or 0.30 m (unit="si"), matching the original module. Set it from your own survey error analysis; it directly scales the reported quantities.

Unit handling. With unit="us" inputs are read as feet and volumes reported in cubic yards; with unit="si", metres and cubic metres. The raster’s own units are not inspected, so this must match your data.

Verification

The integration is checked against twelve cases with exact analytical answers: flat and tilted planes, planes crossing the reference (clipping), non-zero reference planes, the BELOW direction, anisotropic cells, NoData exclusion and draped area on a 45-degree slope (100·√2). A pyramid whose ridges deliberately do not follow the triangulation converges to the exact volume under refinement (1330.0 → 1332.5 → 1333.125 against an exact 1333.33), which is the expected behaviour: a TIN approximates such a surface rather than reproducing it.

See tests/test_volume.py and tests/test_volume_assessment.py.