[{"data":1,"prerenderedAt":2106},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fcalculate-cut-and-fill-volume-pyqgis":3},{"id":4,"title":5,"body":6,"description":2095,"extension":2096,"meta":2097,"navigation":180,"path":2102,"seo":2103,"stem":2104,"__hash__":2105},"docs\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fcalculate-cut-and-fill-volume-pyqgis\u002Findex.md","Calculate Cut and Fill Volume in PyQGIS",{"type":7,"value":8,"toc":2081},"minimark",[9,13,17,26,105,110,128,132,140,486,497,501,504,595,999,1017,1021,1024,1218,1223,1227,1230,1585,1595,1599,1602,1774,1779,1783,1786,1854,1948,1953,1957,1977,1981,2007,2011,2017,2021,2027,2033,2039,2045,2049,2077],[10,11,5],"h1",{"id":12},"calculate-cut-and-fill-volume-in-pyqgis",[14,15,16],"p",{},"Earthworks are measured in cubic metres: how much soil must be excavated to level a building platform, how much fill a road embankment needs, how large a gravel stockpile is today compared with last month. With elevation rasters the calculation is a sum over cells — the height difference in each cell times the cell's area — and the details that matter are the reference surface, the boundary of the site, NoData handling, and the precision the inputs really support.",[14,18,19,20,25],{},"This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002F","Terrain & Interpolation Analysis",". It computes volume against a flat design level with Processing, cut and fill between two surfaces with NumPy, stockpile volumes from repeated surveys, restricts every calculation to a site boundary, and reports results with appropriate precision.",[14,27,28],{},[29,30,35,39,43,50,59,66,74,81,86,89,95,100],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 262","img","A terrain profile crossed by a design level, with cut where ground is above it and fill where ground is below it","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"Cut and fill against a design level",[40,41,42],"desc",{},"A terrain profile crossed by a horizontal design level. Where existing ground is above the design level, material must be cut, shown in amber. Where it is below, material must be filled, shown in blue. Volume is the sum over cells of the height difference times the cell area, kept separately for cut and fill.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","262","#f6f3ea",[51,52,58],"text",{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"380","28","text-anchor:middle;font-size:14px;font-family:sans-serif;font-weight:bold","#17211d","middle","Above the level: cut; below: fill",[60,61],"path",{"d":62,"fill":63,"stroke":64,"style":65},"M 40 120 C 120 80 180 90 260 130 C 340 170 400 210 480 190 C 560 170 620 120 720 110","none","#59645f","stroke-width:3",[67,68],"line",{"x1":69,"y1":70,"x2":71,"y2":70,"stroke":72,"style":73},"40","150","720","#2f3b35","stroke-width:2;stroke-dasharray:6 4",[75,76],"polygon",{"points":77,"fill":78,"stroke":79,"style":80},"60,150 80,113 120,95 180,96 240,120 290,150","#fdf2e2","#b45309","stroke-width:1.5",[75,82],{"points":83,"fill":84,"stroke":85,"style":80},"300,150 340,170 400,206 480,190 540,170 580,150","#eff3ff","#2563eb",[75,87],{"points":88,"fill":78,"stroke":79,"style":80},"590,150 620,126 700,112 720,112 720,150",[51,90,94],{"x":91,"y":92,"style":93,"fill":79,"textAnchor":57},"170","128","text-anchor:middle;font-size:12.0px;font-family:sans-serif;font-weight:bold","cut",[51,96,99],{"x":97,"y":98,"style":93,"fill":85,"textAnchor":57},"440","175","fill",[51,101,104],{"x":53,"y":102,"style":103,"fill":64,"textAnchor":57},"242","text-anchor:middle;font-size:10.5px;font-family:sans-serif","design level — volume = Σ (Δh × cell area)",[106,107,109],"h2",{"id":108},"prerequisites","Prerequisites",[111,112,113,117,125],"ul",{},[114,115,116],"li",{},"QGIS 3.34 LTR or newer, or the QGIS 4 series.",[114,118,119,120,124],{},"Elevation rasters in a projected CRS with metre units for both horizontal and vertical values. Surveys from drones or LiDAR are typical sources; ",[21,121,123],{"href":122},"\u002Fspatial-data-processing-automation\u002Fpoint-cloud-and-lidar-workflows\u002Fcreate-dem-from-point-cloud-pyqgis\u002F","creating a DEM from a point cloud"," shows how to produce one.",[114,126,127],{},"A site boundary polygon.",[106,129,131],{"id":130},"volume-against-a-flat-level","Volume against a flat level",[14,133,134,135,139],{},"For a platform at a single design elevation, ",[136,137,138],"code",{},"native:rastersurfacevolume"," computes the volume above or below that level directly.",[141,142,147],"pre",{"className":143,"code":144,"language":145,"meta":146,"style":146},"language-python shiki shiki-themes github-dark","import processing\nfrom qgis.core import QgsRasterLayer, QgsVectorLayer\n\nsite = QgsVectorLayer(\"\u002Fdata\u002Fsite\u002Fplatform_boundary.gpkg\", \"site\", \"ogr\")\ndem = processing.run(\"gdal:cliprasterbymasklayer\", {\n    \"INPUT\": \"\u002Fdata\u002Fsite\u002Fsurvey_2026_09.tif\", \"MASK\": site, \"CROP_TO_CUTLINE\": True,\n    \"KEEP_RESOLUTION\": True, \"NODATA\": -9999, \"OUTPUT\": \"TEMPORARY_OUTPUT\"})[\"OUTPUT\"]\n\nLEVEL = 112.40                       # design platform elevation, metres\nfor method, label in ((0, \"above level (cut)\"), (1, \"below level (fill)\")):\n    r = processing.run(\"native:rastersurfacevolume\", {\n        \"INPUT\": dem, \"BAND\": 1, \"LEVEL\": LEVEL, \"METHOD\": method,\n        \"OUTPUT_HTML_FILE\": \"TEMPORARY_OUTPUT\"})\n    print(f\"{label:\u003C20} {r['VOLUME']:12,.1f} m³ over {r['AREA']:,.0f} m²\")\n","python","",[136,148,149,161,175,182,212,229,261,302,307,323,359,374,407,420],{"__ignoreMap":146},[150,151,153,157],"span",{"class":67,"line":152},1,[150,154,156],{"class":155},"snl16","import",[150,158,160],{"class":159},"s95oV"," processing\n",[150,162,164,167,170,172],{"class":67,"line":163},2,[150,165,166],{"class":155},"from",[150,168,169],{"class":159}," qgis.core ",[150,171,156],{"class":155},[150,173,174],{"class":159}," QgsRasterLayer, QgsVectorLayer\n",[150,176,178],{"class":67,"line":177},3,[150,179,181],{"emptyLinePlaceholder":180},true,"\n",[150,183,185,188,191,194,198,201,204,206,209],{"class":67,"line":184},4,[150,186,187],{"class":159},"site ",[150,189,190],{"class":155},"=",[150,192,193],{"class":159}," QgsVectorLayer(",[150,195,197],{"class":196},"sU2Wk","\"\u002Fdata\u002Fsite\u002Fplatform_boundary.gpkg\"",[150,199,200],{"class":159},", ",[150,202,203],{"class":196},"\"site\"",[150,205,200],{"class":159},[150,207,208],{"class":196},"\"ogr\"",[150,210,211],{"class":159},")\n",[150,213,215,218,220,223,226],{"class":67,"line":214},5,[150,216,217],{"class":159},"dem ",[150,219,190],{"class":155},[150,221,222],{"class":159}," processing.run(",[150,224,225],{"class":196},"\"gdal:cliprasterbymasklayer\"",[150,227,228],{"class":159},", {\n",[150,230,232,235,238,241,243,246,249,252,254,258],{"class":67,"line":231},6,[150,233,234],{"class":196},"    \"INPUT\"",[150,236,237],{"class":159},": ",[150,239,240],{"class":196},"\"\u002Fdata\u002Fsite\u002Fsurvey_2026_09.tif\"",[150,242,200],{"class":159},[150,244,245],{"class":196},"\"MASK\"",[150,247,248],{"class":159},": site, ",[150,250,251],{"class":196},"\"CROP_TO_CUTLINE\"",[150,253,237],{"class":159},[150,255,257],{"class":256},"sDLfK","True",[150,259,260],{"class":159},",\n",[150,262,264,267,269,271,273,276,278,281,284,286,289,291,294,297,299],{"class":67,"line":263},7,[150,265,266],{"class":196},"    \"KEEP_RESOLUTION\"",[150,268,237],{"class":159},[150,270,257],{"class":256},[150,272,200],{"class":159},[150,274,275],{"class":196},"\"NODATA\"",[150,277,237],{"class":159},[150,279,280],{"class":155},"-",[150,282,283],{"class":256},"9999",[150,285,200],{"class":159},[150,287,288],{"class":196},"\"OUTPUT\"",[150,290,237],{"class":159},[150,292,293],{"class":196},"\"TEMPORARY_OUTPUT\"",[150,295,296],{"class":159},"})[",[150,298,288],{"class":196},[150,300,301],{"class":159},"]\n",[150,303,305],{"class":67,"line":304},8,[150,306,181],{"emptyLinePlaceholder":180},[150,308,310,313,316,319],{"class":67,"line":309},9,[150,311,312],{"class":256},"LEVEL",[150,314,315],{"class":155}," =",[150,317,318],{"class":256}," 112.40",[150,320,322],{"class":321},"sjoCn","                       # design platform elevation, metres\n",[150,324,326,329,332,335,338,340,342,345,348,351,353,356],{"class":67,"line":325},10,[150,327,328],{"class":155},"for",[150,330,331],{"class":159}," method, label ",[150,333,334],{"class":155},"in",[150,336,337],{"class":159}," ((",[150,339,46],{"class":256},[150,341,200],{"class":159},[150,343,344],{"class":196},"\"above level (cut)\"",[150,346,347],{"class":159},"), (",[150,349,350],{"class":256},"1",[150,352,200],{"class":159},[150,354,355],{"class":196},"\"below level (fill)\"",[150,357,358],{"class":159},")):\n",[150,360,362,365,367,369,372],{"class":67,"line":361},11,[150,363,364],{"class":159},"    r ",[150,366,190],{"class":155},[150,368,222],{"class":159},[150,370,371],{"class":196},"\"native:rastersurfacevolume\"",[150,373,228],{"class":159},[150,375,377,380,383,386,388,390,392,395,397,399,401,404],{"class":67,"line":376},12,[150,378,379],{"class":196},"        \"INPUT\"",[150,381,382],{"class":159},": dem, ",[150,384,385],{"class":196},"\"BAND\"",[150,387,237],{"class":159},[150,389,350],{"class":256},[150,391,200],{"class":159},[150,393,394],{"class":196},"\"LEVEL\"",[150,396,237],{"class":159},[150,398,312],{"class":256},[150,400,200],{"class":159},[150,402,403],{"class":196},"\"METHOD\"",[150,405,406],{"class":159},": method,\n",[150,408,410,413,415,417],{"class":67,"line":409},13,[150,411,412],{"class":196},"        \"OUTPUT_HTML_FILE\"",[150,414,237],{"class":159},[150,416,293],{"class":196},[150,418,419],{"class":159},"})\n",[150,421,423,426,429,432,435,438,441,444,447,450,453,456,459,462,464,467,469,471,474,476,479,481,484],{"class":67,"line":422},14,[150,424,425],{"class":256},"    print",[150,427,428],{"class":159},"(",[150,430,431],{"class":155},"f",[150,433,434],{"class":196},"\"",[150,436,437],{"class":256},"{",[150,439,440],{"class":159},"label",[150,442,443],{"class":155},":\u003C20",[150,445,446],{"class":256},"}",[150,448,449],{"class":256}," {",[150,451,452],{"class":159},"r[",[150,454,455],{"class":196},"'VOLUME'",[150,457,458],{"class":159},"]",[150,460,461],{"class":155},":12,.1f",[150,463,446],{"class":256},[150,465,466],{"class":196}," m³ over ",[150,468,437],{"class":256},[150,470,452],{"class":159},[150,472,473],{"class":196},"'AREA'",[150,475,458],{"class":159},[150,477,478],{"class":155},":,.0f",[150,480,446],{"class":256},[150,482,483],{"class":196}," m²\"",[150,485,211],{"class":159},[14,487,488,492,493,496],{},[489,490,491],"strong",{},"Breakdown:"," Clipping to the site boundary first makes sure only cells inside the platform count — volume outside the site is irrelevant and, at the edges of a survey, often unreliable. ",[136,494,495],{},"METHOD"," 0 counts only cells above the level, giving the cut; 1 counts cells below, giving the fill; other methods return the net or absolute total. The algorithm reports the volume, the area of the cells that contributed and a pixel count. For a balanced earthwork, adjust the level until cut and fill are roughly equal, which a short loop over candidate levels does quickly.",[106,498,500],{"id":499},"cut-and-fill-between-two-surfaces","Cut and fill between two surfaces",[14,502,503],{},"Most designs are not flat: road corridors, sloped platforms, landscaped mounds. With a design surface as a raster on the same grid, cut and fill come from the cell-by-cell difference.",[14,505,506],{},[29,507,510,513,516,519,534,537,545,551,555,558,562,565,570,576,581,585,587,591],{"viewBox":508,"role":32,"ariaLabel":509,"xmlns":34},"0 0 760 208","An existing surface minus a design surface giving positive cells as cut and negative cells as fill, summed separately",[36,511,512],{},"Difference of two surfaces",[40,514,515],{},"The existing surface minus the design surface gives a difference raster. Positive cells need cutting, negative cells need filling. Multiplying by cell area and summing positive and negative parts separately gives cut and fill volumes. Both rasters must share the same grid, and NoData in either input must be excluded from both sums.",[44,517],{"x":46,"y":46,"width":47,"height":518,"fill":49},"208",[520,521,522],"defs",{},[523,524,531],"marker",{"id":525,"viewBox":526,"refX":527,"refY":528,"markerWidth":529,"markerHeight":529,"orient":530},"cfDiffArrow","0 0 10 10","8","5","7","auto-start-reverse",[60,532],{"d":533,"fill":72},"M0 0 L10 5 L0 10 z",[51,535,536],{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"existing − design",[44,538],{"x":539,"y":540,"width":541,"height":542,"rx":527,"fill":543,"stroke":64,"style":544},"24","60","180","120","#fffdf7","stroke-width:2",[51,546,550],{"x":547,"y":548,"style":549,"fill":56,"textAnchor":57},"114","110.78","text-anchor:middle;font-size:11.5px;font-family:sans-serif;font-weight:bold","existing DEM",[51,552,554],{"x":547,"y":553,"style":103,"fill":64,"textAnchor":57},"136.78","survey",[44,556],{"x":557,"y":540,"width":541,"height":542,"rx":527,"fill":84,"stroke":85,"style":544},"220",[51,559,561],{"x":560,"y":548,"style":549,"fill":85,"textAnchor":57},"310","design surface",[51,563,564],{"x":560,"y":553,"style":103,"fill":64,"textAnchor":57},"same grid",[67,566],{"x1":567,"y1":542,"x2":568,"y2":542,"stroke":72,"style":569},"400","442","stroke-width:1.8;marker-end:url(#cfDiffArrow)",[44,571],{"x":572,"y":573,"width":574,"height":575,"rx":527,"fill":78,"stroke":79,"style":544},"446","52","290","64",[51,577,580],{"x":578,"y":579,"style":549,"fill":79,"textAnchor":57},"591","77.78","Δh > 0 → cut",[51,582,584],{"x":578,"y":583,"style":103,"fill":64,"textAnchor":57},"97.78","Σ Δh × area",[44,586],{"x":572,"y":92,"width":574,"height":575,"rx":527,"fill":84,"stroke":85,"style":544},[51,588,590],{"x":578,"y":589,"style":549,"fill":85,"textAnchor":57},"153.78","Δh \u003C 0 → fill",[51,592,594],{"x":578,"y":593,"style":103,"fill":64,"textAnchor":57},"173.78","Σ |Δh| × area",[141,596,598],{"className":143,"code":597,"language":145,"meta":146,"style":146},"import numpy as np\nfrom osgeo import gdal\n\ndef read(path):\n    ds = gdal.Open(path)\n    band = ds.GetRasterBand(1)\n    arr = band.ReadAsArray().astype(\"float64\")\n    nd = band.GetNoDataValue()\n    if nd is not None:\n        arr[arr == nd] = np.nan\n    return arr, ds.GetGeoTransform()\n\nexisting, gt1 = read(\"\u002Fdata\u002Fsite\u002Fexisting_clip.tif\")\ndesign, gt2 = read(\"\u002Fdata\u002Fsite\u002Fdesign_surface.tif\")\nassert gt1 == gt2 and existing.shape == design.shape, \"surfaces are not on the same grid\"\n\ncell_area = abs(gt1[1] * gt1[5])\ndiff = existing - design\nvalid = ~np.isnan(diff)\ncut = np.nansum(np.where(diff > 0, diff, 0)) * cell_area\nfill = np.nansum(np.where(diff \u003C 0, -diff, 0)) * cell_area\nprint(f\"cut {cut:,.0f} m³ · fill {fill:,.0f} m³ · net {cut - fill:+,.0f} m³ \"\n      f\"over {valid.sum() * cell_area:,.0f} m²\")\n",[136,599,600,613,625,629,641,651,665,680,690,710,726,734,738,753,767,795,800,830,846,860,890,920,972],{"__ignoreMap":146},[150,601,602,604,607,610],{"class":67,"line":152},[150,603,156],{"class":155},[150,605,606],{"class":159}," numpy ",[150,608,609],{"class":155},"as",[150,611,612],{"class":159}," np\n",[150,614,615,617,620,622],{"class":67,"line":163},[150,616,166],{"class":155},[150,618,619],{"class":159}," osgeo ",[150,621,156],{"class":155},[150,623,624],{"class":159}," gdal\n",[150,626,627],{"class":67,"line":177},[150,628,181],{"emptyLinePlaceholder":180},[150,630,631,634,638],{"class":67,"line":184},[150,632,633],{"class":155},"def",[150,635,637],{"class":636},"svObZ"," read",[150,639,640],{"class":159},"(path):\n",[150,642,643,646,648],{"class":67,"line":214},[150,644,645],{"class":159},"    ds ",[150,647,190],{"class":155},[150,649,650],{"class":159}," gdal.Open(path)\n",[150,652,653,656,658,661,663],{"class":67,"line":231},[150,654,655],{"class":159},"    band ",[150,657,190],{"class":155},[150,659,660],{"class":159}," ds.GetRasterBand(",[150,662,350],{"class":256},[150,664,211],{"class":159},[150,666,667,670,672,675,678],{"class":67,"line":263},[150,668,669],{"class":159},"    arr ",[150,671,190],{"class":155},[150,673,674],{"class":159}," band.ReadAsArray().astype(",[150,676,677],{"class":196},"\"float64\"",[150,679,211],{"class":159},[150,681,682,685,687],{"class":67,"line":304},[150,683,684],{"class":159},"    nd ",[150,686,190],{"class":155},[150,688,689],{"class":159}," band.GetNoDataValue()\n",[150,691,692,695,698,701,704,707],{"class":67,"line":309},[150,693,694],{"class":155},"    if",[150,696,697],{"class":159}," nd ",[150,699,700],{"class":155},"is",[150,702,703],{"class":155}," not",[150,705,706],{"class":256}," None",[150,708,709],{"class":159},":\n",[150,711,712,715,718,721,723],{"class":67,"line":325},[150,713,714],{"class":159},"        arr[arr ",[150,716,717],{"class":155},"==",[150,719,720],{"class":159}," nd] ",[150,722,190],{"class":155},[150,724,725],{"class":159}," np.nan\n",[150,727,728,731],{"class":67,"line":361},[150,729,730],{"class":155},"    return",[150,732,733],{"class":159}," arr, ds.GetGeoTransform()\n",[150,735,736],{"class":67,"line":376},[150,737,181],{"emptyLinePlaceholder":180},[150,739,740,743,745,748,751],{"class":67,"line":409},[150,741,742],{"class":159},"existing, gt1 ",[150,744,190],{"class":155},[150,746,747],{"class":159}," read(",[150,749,750],{"class":196},"\"\u002Fdata\u002Fsite\u002Fexisting_clip.tif\"",[150,752,211],{"class":159},[150,754,755,758,760,762,765],{"class":67,"line":422},[150,756,757],{"class":159},"design, gt2 ",[150,759,190],{"class":155},[150,761,747],{"class":159},[150,763,764],{"class":196},"\"\u002Fdata\u002Fsite\u002Fdesign_surface.tif\"",[150,766,211],{"class":159},[150,768,770,773,776,778,781,784,787,789,792],{"class":67,"line":769},15,[150,771,772],{"class":155},"assert",[150,774,775],{"class":159}," gt1 ",[150,777,717],{"class":155},[150,779,780],{"class":159}," gt2 ",[150,782,783],{"class":155},"and",[150,785,786],{"class":159}," existing.shape ",[150,788,717],{"class":155},[150,790,791],{"class":159}," design.shape, ",[150,793,794],{"class":196},"\"surfaces are not on the same grid\"\n",[150,796,798],{"class":67,"line":797},16,[150,799,181],{"emptyLinePlaceholder":180},[150,801,803,806,808,811,814,816,819,822,825,827],{"class":67,"line":802},17,[150,804,805],{"class":159},"cell_area ",[150,807,190],{"class":155},[150,809,810],{"class":256}," abs",[150,812,813],{"class":159},"(gt1[",[150,815,350],{"class":256},[150,817,818],{"class":159},"] ",[150,820,821],{"class":155},"*",[150,823,824],{"class":159}," gt1[",[150,826,528],{"class":256},[150,828,829],{"class":159},"])\n",[150,831,833,836,838,841,843],{"class":67,"line":832},18,[150,834,835],{"class":159},"diff ",[150,837,190],{"class":155},[150,839,840],{"class":159}," existing ",[150,842,280],{"class":155},[150,844,845],{"class":159}," design\n",[150,847,849,852,854,857],{"class":67,"line":848},19,[150,850,851],{"class":159},"valid ",[150,853,190],{"class":155},[150,855,856],{"class":155}," ~",[150,858,859],{"class":159},"np.isnan(diff)\n",[150,861,863,866,868,871,874,877,880,882,885,887],{"class":67,"line":862},20,[150,864,865],{"class":159},"cut ",[150,867,190],{"class":155},[150,869,870],{"class":159}," np.nansum(np.where(diff ",[150,872,873],{"class":155},">",[150,875,876],{"class":256}," 0",[150,878,879],{"class":159},", diff, ",[150,881,46],{"class":256},[150,883,884],{"class":159},")) ",[150,886,821],{"class":155},[150,888,889],{"class":159}," cell_area\n",[150,891,893,896,898,900,903,905,907,909,912,914,916,918],{"class":67,"line":892},21,[150,894,895],{"class":159},"fill ",[150,897,190],{"class":155},[150,899,870],{"class":159},[150,901,902],{"class":155},"\u003C",[150,904,876],{"class":256},[150,906,200],{"class":159},[150,908,280],{"class":155},[150,910,911],{"class":159},"diff, ",[150,913,46],{"class":256},[150,915,884],{"class":159},[150,917,821],{"class":155},[150,919,889],{"class":159},[150,921,923,926,928,930,933,935,937,939,941,944,946,948,950,952,955,957,959,961,964,967,969],{"class":67,"line":922},22,[150,924,925],{"class":256},"print",[150,927,428],{"class":159},[150,929,431],{"class":155},[150,931,932],{"class":196},"\"cut ",[150,934,437],{"class":256},[150,936,94],{"class":159},[150,938,478],{"class":155},[150,940,446],{"class":256},[150,942,943],{"class":196}," m³ · fill ",[150,945,437],{"class":256},[150,947,99],{"class":159},[150,949,478],{"class":155},[150,951,446],{"class":256},[150,953,954],{"class":196}," m³ · net ",[150,956,437],{"class":256},[150,958,865],{"class":159},[150,960,280],{"class":155},[150,962,963],{"class":159}," fill",[150,965,966],{"class":155},":+,.0f",[150,968,446],{"class":256},[150,970,971],{"class":196}," m³ \"\n",[150,973,975,978,981,983,986,988,991,993,995,997],{"class":67,"line":974},23,[150,976,977],{"class":155},"      f",[150,979,980],{"class":196},"\"over ",[150,982,437],{"class":256},[150,984,985],{"class":159},"valid.sum() ",[150,987,821],{"class":155},[150,989,990],{"class":159}," cell_area",[150,992,478],{"class":155},[150,994,446],{"class":256},[150,996,483],{"class":196},[150,998,211],{"class":159},[14,1000,1001,1003,1004,1007,1008,1012,1013,1016],{},[489,1002,491],{}," Converting NoData to NaN in both arrays and summing with ",[136,1005,1006],{},"nansum"," excludes any cell missing in either surface, so holes in the survey do not count as zero height. The assertion on geotransform and shape is essential: subtracting rasters on different grids gives nonsense without any error. Align the design surface to the survey grid first, as in ",[21,1009,1011],{"href":1010},"\u002Fspatial-data-processing-automation\u002Fraster-analysis-workflows\u002Fresample-and-align-rasters-pyqgis\u002F","resampling and aligning rasters",". Cell area from the geotransform makes the calculation work at any resolution. Writing ",[136,1014,1015],{},"diff"," back to a GeoTIFF gives a cut\u002Ffill map — style it with a diverging ramp centred on zero.",[106,1018,1020],{"id":1019},"find-the-balanced-platform-level","Find the balanced platform level",[14,1022,1023],{},"Moving soil off site is expensive, so platforms are often set at the level where cut equals fill. With the clipped survey as an array, a quick search finds it.",[141,1025,1027],{"className":143,"code":1026,"language":145,"meta":146,"style":146},"site_dem, _ = read(dem)\n\ndef net_volume(level):\n    d = site_dem - level\n    return np.nansum(d) * cell_area           # positive: surplus cut\n\nlo, hi = np.nanmin(site_dem), np.nanmax(site_dem)\nfor _ in range(40):                           # bisection\n    mid = (lo + hi) \u002F 2\n    if net_volume(mid) > 0:\n        lo = mid\n    else:\n        hi = mid\nprint(f\"balanced level ≈ {mid:.2f} m (net {net_volume(mid):+,.0f} m³)\")\n",[136,1028,1029,1039,1043,1053,1068,1083,1087,1097,1119,1141,1154,1164,1171,1180],{"__ignoreMap":146},[150,1030,1031,1034,1036],{"class":67,"line":152},[150,1032,1033],{"class":159},"site_dem, _ ",[150,1035,190],{"class":155},[150,1037,1038],{"class":159}," read(dem)\n",[150,1040,1041],{"class":67,"line":163},[150,1042,181],{"emptyLinePlaceholder":180},[150,1044,1045,1047,1050],{"class":67,"line":177},[150,1046,633],{"class":155},[150,1048,1049],{"class":636}," net_volume",[150,1051,1052],{"class":159},"(level):\n",[150,1054,1055,1058,1060,1063,1065],{"class":67,"line":184},[150,1056,1057],{"class":159},"    d ",[150,1059,190],{"class":155},[150,1061,1062],{"class":159}," site_dem ",[150,1064,280],{"class":155},[150,1066,1067],{"class":159}," level\n",[150,1069,1070,1072,1075,1077,1080],{"class":67,"line":214},[150,1071,730],{"class":155},[150,1073,1074],{"class":159}," np.nansum(d) ",[150,1076,821],{"class":155},[150,1078,1079],{"class":159}," cell_area           ",[150,1081,1082],{"class":321},"# positive: surplus cut\n",[150,1084,1085],{"class":67,"line":231},[150,1086,181],{"emptyLinePlaceholder":180},[150,1088,1089,1092,1094],{"class":67,"line":263},[150,1090,1091],{"class":159},"lo, hi ",[150,1093,190],{"class":155},[150,1095,1096],{"class":159}," np.nanmin(site_dem), np.nanmax(site_dem)\n",[150,1098,1099,1101,1104,1106,1109,1111,1113,1116],{"class":67,"line":304},[150,1100,328],{"class":155},[150,1102,1103],{"class":159}," _ ",[150,1105,334],{"class":155},[150,1107,1108],{"class":256}," range",[150,1110,428],{"class":159},[150,1112,69],{"class":256},[150,1114,1115],{"class":159},"):                           ",[150,1117,1118],{"class":321},"# bisection\n",[150,1120,1121,1124,1126,1129,1132,1135,1138],{"class":67,"line":309},[150,1122,1123],{"class":159},"    mid ",[150,1125,190],{"class":155},[150,1127,1128],{"class":159}," (lo ",[150,1130,1131],{"class":155},"+",[150,1133,1134],{"class":159}," hi) ",[150,1136,1137],{"class":155},"\u002F",[150,1139,1140],{"class":256}," 2\n",[150,1142,1143,1145,1148,1150,1152],{"class":67,"line":325},[150,1144,694],{"class":155},[150,1146,1147],{"class":159}," net_volume(mid) ",[150,1149,873],{"class":155},[150,1151,876],{"class":256},[150,1153,709],{"class":159},[150,1155,1156,1159,1161],{"class":67,"line":361},[150,1157,1158],{"class":159},"        lo ",[150,1160,190],{"class":155},[150,1162,1163],{"class":159}," mid\n",[150,1165,1166,1169],{"class":67,"line":376},[150,1167,1168],{"class":155},"    else",[150,1170,709],{"class":159},[150,1172,1173,1176,1178],{"class":67,"line":409},[150,1174,1175],{"class":159},"        hi ",[150,1177,190],{"class":155},[150,1179,1163],{"class":159},[150,1181,1182,1184,1186,1188,1191,1193,1196,1199,1201,1204,1206,1209,1211,1213,1216],{"class":67,"line":422},[150,1183,925],{"class":256},[150,1185,428],{"class":159},[150,1187,431],{"class":155},[150,1189,1190],{"class":196},"\"balanced level ≈ ",[150,1192,437],{"class":256},[150,1194,1195],{"class":159},"mid",[150,1197,1198],{"class":155},":.2f",[150,1200,446],{"class":256},[150,1202,1203],{"class":196}," m (net ",[150,1205,437],{"class":256},[150,1207,1208],{"class":159},"net_volume(mid)",[150,1210,966],{"class":155},[150,1212,446],{"class":256},[150,1214,1215],{"class":196}," m³)\"",[150,1217,211],{"class":159},[14,1219,1220,1222],{},[489,1221,491],{}," The net volume — cut minus fill — falls steadily as the level rises, so bisection between the lowest and highest ground finds the level where it crosses zero in a few dozen steps. In practice a design level is then rounded and adjusted for drainage falls, compaction and the volume of foundations, but the balanced level is the natural starting point and shows immediately whether a proposed level implies importing or exporting material.",[106,1224,1226],{"id":1225},"measure-a-stockpile","Measure a stockpile",[14,1228,1229],{},"A stockpile's volume is the material above the ground it sits on. The ground under the pile is not surveyed, so a base surface is constructed from the pile's edge — typically a plane or a TIN through the boundary elevations.",[141,1231,1233],{"className":143,"code":1232,"language":145,"meta":146,"style":146},"pile = QgsVectorLayer(\"\u002Fdata\u002Fsite\u002Fstockpile_A.gpkg\", \"pile\", \"ogr\")\nedge_pts = processing.run(\"native:pointsalonglines\", {\n    \"INPUT\": processing.run(\"native:polygonstolines\", {\"INPUT\": pile, \"OUTPUT\": \"memory:\"})[\"OUTPUT\"],\n    \"DISTANCE\": 2, \"OUTPUT\": \"memory:\"})[\"OUTPUT\"]\nedge_z = processing.run(\"native:rastersampling\", {\n    \"INPUT\": edge_pts, \"RASTERCOPY\": \"\u002Fdata\u002Fsite\u002Fsurvey_2026_09.tif\",\n    \"COLUMN_PREFIX\": \"z_\", \"OUTPUT\": \"memory:\"})[\"OUTPUT\"]\n\nbase_level = float(np.median([f[\"z_1\"] for f in edge_z.getFeatures() if f[\"z_1\"] is not None]))\nclip = processing.run(\"gdal:cliprasterbymasklayer\", {\n    \"INPUT\": \"\u002Fdata\u002Fsite\u002Fsurvey_2026_09.tif\", \"MASK\": pile, \"CROP_TO_CUTLINE\": True,\n    \"KEEP_RESOLUTION\": True, \"OUTPUT\": \"TEMPORARY_OUTPUT\"})[\"OUTPUT\"]\nvol = processing.run(\"native:rastersurfacevolume\", {\n    \"INPUT\": clip, \"BAND\": 1, \"LEVEL\": base_level, \"METHOD\": 0,\n    \"OUTPUT_HTML_FILE\": \"TEMPORARY_OUTPUT\"})[\"VOLUME\"]\nprint(f\"stockpile A: base {base_level:.2f} m, volume {vol:,.0f} m³\")\n",[136,1234,1235,1258,1272,1305,1329,1343,1359,1383,1387,1434,1447,1469,1491,1504,1532,1548],{"__ignoreMap":146},[150,1236,1237,1240,1242,1244,1247,1249,1252,1254,1256],{"class":67,"line":152},[150,1238,1239],{"class":159},"pile ",[150,1241,190],{"class":155},[150,1243,193],{"class":159},[150,1245,1246],{"class":196},"\"\u002Fdata\u002Fsite\u002Fstockpile_A.gpkg\"",[150,1248,200],{"class":159},[150,1250,1251],{"class":196},"\"pile\"",[150,1253,200],{"class":159},[150,1255,208],{"class":196},[150,1257,211],{"class":159},[150,1259,1260,1263,1265,1267,1270],{"class":67,"line":163},[150,1261,1262],{"class":159},"edge_pts ",[150,1264,190],{"class":155},[150,1266,222],{"class":159},[150,1268,1269],{"class":196},"\"native:pointsalonglines\"",[150,1271,228],{"class":159},[150,1273,1274,1276,1279,1282,1285,1288,1291,1293,1295,1298,1300,1302],{"class":67,"line":177},[150,1275,234],{"class":196},[150,1277,1278],{"class":159},": processing.run(",[150,1280,1281],{"class":196},"\"native:polygonstolines\"",[150,1283,1284],{"class":159},", {",[150,1286,1287],{"class":196},"\"INPUT\"",[150,1289,1290],{"class":159},": pile, ",[150,1292,288],{"class":196},[150,1294,237],{"class":159},[150,1296,1297],{"class":196},"\"memory:\"",[150,1299,296],{"class":159},[150,1301,288],{"class":196},[150,1303,1304],{"class":159},"],\n",[150,1306,1307,1310,1312,1315,1317,1319,1321,1323,1325,1327],{"class":67,"line":184},[150,1308,1309],{"class":196},"    \"DISTANCE\"",[150,1311,237],{"class":159},[150,1313,1314],{"class":256},"2",[150,1316,200],{"class":159},[150,1318,288],{"class":196},[150,1320,237],{"class":159},[150,1322,1297],{"class":196},[150,1324,296],{"class":159},[150,1326,288],{"class":196},[150,1328,301],{"class":159},[150,1330,1331,1334,1336,1338,1341],{"class":67,"line":214},[150,1332,1333],{"class":159},"edge_z ",[150,1335,190],{"class":155},[150,1337,222],{"class":159},[150,1339,1340],{"class":196},"\"native:rastersampling\"",[150,1342,228],{"class":159},[150,1344,1345,1347,1350,1353,1355,1357],{"class":67,"line":231},[150,1346,234],{"class":196},[150,1348,1349],{"class":159},": edge_pts, ",[150,1351,1352],{"class":196},"\"RASTERCOPY\"",[150,1354,237],{"class":159},[150,1356,240],{"class":196},[150,1358,260],{"class":159},[150,1360,1361,1364,1366,1369,1371,1373,1375,1377,1379,1381],{"class":67,"line":263},[150,1362,1363],{"class":196},"    \"COLUMN_PREFIX\"",[150,1365,237],{"class":159},[150,1367,1368],{"class":196},"\"z_\"",[150,1370,200],{"class":159},[150,1372,288],{"class":196},[150,1374,237],{"class":159},[150,1376,1297],{"class":196},[150,1378,296],{"class":159},[150,1380,288],{"class":196},[150,1382,301],{"class":159},[150,1384,1385],{"class":67,"line":304},[150,1386,181],{"emptyLinePlaceholder":180},[150,1388,1389,1392,1394,1397,1400,1403,1405,1407,1410,1412,1415,1418,1421,1423,1425,1427,1429,1431],{"class":67,"line":309},[150,1390,1391],{"class":159},"base_level ",[150,1393,190],{"class":155},[150,1395,1396],{"class":256}," float",[150,1398,1399],{"class":159},"(np.median([f[",[150,1401,1402],{"class":196},"\"z_1\"",[150,1404,818],{"class":159},[150,1406,328],{"class":155},[150,1408,1409],{"class":159}," f ",[150,1411,334],{"class":155},[150,1413,1414],{"class":159}," edge_z.getFeatures() ",[150,1416,1417],{"class":155},"if",[150,1419,1420],{"class":159}," f[",[150,1422,1402],{"class":196},[150,1424,818],{"class":159},[150,1426,700],{"class":155},[150,1428,703],{"class":155},[150,1430,706],{"class":256},[150,1432,1433],{"class":159},"]))\n",[150,1435,1436,1439,1441,1443,1445],{"class":67,"line":325},[150,1437,1438],{"class":159},"clip ",[150,1440,190],{"class":155},[150,1442,222],{"class":159},[150,1444,225],{"class":196},[150,1446,228],{"class":159},[150,1448,1449,1451,1453,1455,1457,1459,1461,1463,1465,1467],{"class":67,"line":361},[150,1450,234],{"class":196},[150,1452,237],{"class":159},[150,1454,240],{"class":196},[150,1456,200],{"class":159},[150,1458,245],{"class":196},[150,1460,1290],{"class":159},[150,1462,251],{"class":196},[150,1464,237],{"class":159},[150,1466,257],{"class":256},[150,1468,260],{"class":159},[150,1470,1471,1473,1475,1477,1479,1481,1483,1485,1487,1489],{"class":67,"line":376},[150,1472,266],{"class":196},[150,1474,237],{"class":159},[150,1476,257],{"class":256},[150,1478,200],{"class":159},[150,1480,288],{"class":196},[150,1482,237],{"class":159},[150,1484,293],{"class":196},[150,1486,296],{"class":159},[150,1488,288],{"class":196},[150,1490,301],{"class":159},[150,1492,1493,1496,1498,1500,1502],{"class":67,"line":409},[150,1494,1495],{"class":159},"vol ",[150,1497,190],{"class":155},[150,1499,222],{"class":159},[150,1501,371],{"class":196},[150,1503,228],{"class":159},[150,1505,1506,1508,1511,1513,1515,1517,1519,1521,1524,1526,1528,1530],{"class":67,"line":422},[150,1507,234],{"class":196},[150,1509,1510],{"class":159},": clip, ",[150,1512,385],{"class":196},[150,1514,237],{"class":159},[150,1516,350],{"class":256},[150,1518,200],{"class":159},[150,1520,394],{"class":196},[150,1522,1523],{"class":159},": base_level, ",[150,1525,403],{"class":196},[150,1527,237],{"class":159},[150,1529,46],{"class":256},[150,1531,260],{"class":159},[150,1533,1534,1537,1539,1541,1543,1546],{"class":67,"line":769},[150,1535,1536],{"class":196},"    \"OUTPUT_HTML_FILE\"",[150,1538,237],{"class":159},[150,1540,293],{"class":196},[150,1542,296],{"class":159},[150,1544,1545],{"class":196},"\"VOLUME\"",[150,1547,301],{"class":159},[150,1549,1550,1552,1554,1556,1559,1561,1564,1566,1568,1571,1573,1576,1578,1580,1583],{"class":67,"line":797},[150,1551,925],{"class":256},[150,1553,428],{"class":159},[150,1555,431],{"class":155},[150,1557,1558],{"class":196},"\"stockpile A: base ",[150,1560,437],{"class":256},[150,1562,1563],{"class":159},"base_level",[150,1565,1198],{"class":155},[150,1567,446],{"class":256},[150,1569,1570],{"class":196}," m, volume ",[150,1572,437],{"class":256},[150,1574,1575],{"class":159},"vol",[150,1577,478],{"class":155},[150,1579,446],{"class":256},[150,1581,1582],{"class":196}," m³\"",[150,1584,211],{"class":159},[14,1586,1587,1589,1590,1594],{},[489,1588,491],{}," Sampling the surveyed surface every two metres around the pile's outline gives the elevations where the pile meets the ground; the median is a robust flat base level that ignores a few odd edge samples. For piles on sloping ground, a flat base under- or over-counts — build a TIN from the edge points with ",[21,1591,1593],{"href":1592},"\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fbuild-tin-interpolation-pyqgis\u002F","TIN interpolation"," and use the two-surface method instead. Repeating the measurement on monthly surveys with the same outline and method gives a consistent inventory trend.",[106,1596,1598],{"id":1597},"compare-surveys-over-time","Compare surveys over time",[14,1600,1601],{},"Monitoring sites — quarries, landfills, construction — compare consecutive surveys to measure what moved. The two-surface method applied to two dates gives removed and added volumes.",[141,1603,1605],{"className":143,"code":1604,"language":145,"meta":146,"style":146},"before, gt_a = read(\"\u002Fdata\u002Fsite\u002Fsurvey_2026_08.tif\")\nafter, gt_b = read(\"\u002Fdata\u002Fsite\u002Fsurvey_2026_09.tif\")\nassert gt_a == gt_b\nchange = after - before\nnoise = 0.05                                    # metres: survey vertical accuracy\nremoved = np.nansum(np.where(change \u003C -noise, -change, 0)) * cell_area\nadded = np.nansum(np.where(change > noise, change, 0)) * cell_area\nprint(f\"removed {removed:,.0f} m³ · added {added:,.0f} m³ (changes under {noise} m ignored)\")\n",[136,1606,1607,1621,1634,1646,1661,1674,1705,1727],{"__ignoreMap":146},[150,1608,1609,1612,1614,1616,1619],{"class":67,"line":152},[150,1610,1611],{"class":159},"before, gt_a ",[150,1613,190],{"class":155},[150,1615,747],{"class":159},[150,1617,1618],{"class":196},"\"\u002Fdata\u002Fsite\u002Fsurvey_2026_08.tif\"",[150,1620,211],{"class":159},[150,1622,1623,1626,1628,1630,1632],{"class":67,"line":163},[150,1624,1625],{"class":159},"after, gt_b ",[150,1627,190],{"class":155},[150,1629,747],{"class":159},[150,1631,240],{"class":196},[150,1633,211],{"class":159},[150,1635,1636,1638,1641,1643],{"class":67,"line":177},[150,1637,772],{"class":155},[150,1639,1640],{"class":159}," gt_a ",[150,1642,717],{"class":155},[150,1644,1645],{"class":159}," gt_b\n",[150,1647,1648,1651,1653,1656,1658],{"class":67,"line":184},[150,1649,1650],{"class":159},"change ",[150,1652,190],{"class":155},[150,1654,1655],{"class":159}," after ",[150,1657,280],{"class":155},[150,1659,1660],{"class":159}," before\n",[150,1662,1663,1666,1668,1671],{"class":67,"line":214},[150,1664,1665],{"class":159},"noise ",[150,1667,190],{"class":155},[150,1669,1670],{"class":256}," 0.05",[150,1672,1673],{"class":321},"                                    # metres: survey vertical accuracy\n",[150,1675,1676,1679,1681,1684,1686,1689,1692,1694,1697,1699,1701,1703],{"class":67,"line":231},[150,1677,1678],{"class":159},"removed ",[150,1680,190],{"class":155},[150,1682,1683],{"class":159}," np.nansum(np.where(change ",[150,1685,902],{"class":155},[150,1687,1688],{"class":155}," -",[150,1690,1691],{"class":159},"noise, ",[150,1693,280],{"class":155},[150,1695,1696],{"class":159},"change, ",[150,1698,46],{"class":256},[150,1700,884],{"class":159},[150,1702,821],{"class":155},[150,1704,889],{"class":159},[150,1706,1707,1710,1712,1714,1716,1719,1721,1723,1725],{"class":67,"line":263},[150,1708,1709],{"class":159},"added ",[150,1711,190],{"class":155},[150,1713,1683],{"class":159},[150,1715,873],{"class":155},[150,1717,1718],{"class":159}," noise, change, ",[150,1720,46],{"class":256},[150,1722,884],{"class":159},[150,1724,821],{"class":155},[150,1726,889],{"class":159},[150,1728,1729,1731,1733,1735,1738,1740,1743,1745,1747,1750,1752,1755,1757,1759,1762,1764,1767,1769,1772],{"class":67,"line":304},[150,1730,925],{"class":256},[150,1732,428],{"class":159},[150,1734,431],{"class":155},[150,1736,1737],{"class":196},"\"removed ",[150,1739,437],{"class":256},[150,1741,1742],{"class":159},"removed",[150,1744,478],{"class":155},[150,1746,446],{"class":256},[150,1748,1749],{"class":196}," m³ · added ",[150,1751,437],{"class":256},[150,1753,1754],{"class":159},"added",[150,1756,478],{"class":155},[150,1758,446],{"class":256},[150,1760,1761],{"class":196}," m³ (changes under ",[150,1763,437],{"class":256},[150,1765,1766],{"class":159},"noise",[150,1768,446],{"class":256},[150,1770,1771],{"class":196}," m ignored)\"",[150,1773,211],{"class":159},[14,1775,1776,1778],{},[489,1777,491],{}," A noise threshold equal to the surveys' vertical accuracy stops random measurement error from accumulating into large fictitious volumes: over a 10-hectare site, an unbiased 3 cm error in every cell adds up to thousands of cubic metres if summed blindly in both directions. Treat the threshold as part of the method and report it with the result. Systematic offsets between surveys — a datum change, a drone flight with poor ground control — are worse; check stable areas such as roads, where the change should be zero.",[106,1780,1782],{"id":1781},"report-with-honest-precision","Report with honest precision",[14,1784,1785],{},"Volume precision is limited by the vertical accuracy of the surfaces, not by the number of digits the computer prints.",[14,1787,1788],{},[29,1789,1792,1795,1798,1801,1804,1808,1813,1817,1823,1827,1831,1834,1837,1841,1846,1850],{"viewBox":1790,"role":32,"ariaLabel":1791,"xmlns":34},"0 0 760 230","A volume uncertainty estimated from vertical accuracy times area, and reporting rounded volumes with that uncertainty",[36,1793,1794],{},"Uncertainty from vertical accuracy",[40,1796,1797],{},"A rough uncertainty for a volume is the vertical accuracy of the surface times the area. A drone survey accurate to 5 centimetres over 2 hectares gives about plus or minus 1000 cubic metres in the worst systematic case. Reporting 12,846.37 cubic metres implies false precision; 12,800 plus or minus 1,000 cubic metres is honest.",[44,1799],{"x":46,"y":46,"width":47,"height":1800,"fill":49},"230",[51,1802,1803],{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"Accuracy × area bounds the answer",[44,1805],{"x":539,"y":1806,"width":1807,"height":70,"rx":527,"fill":543,"stroke":64,"style":544},"56","222",[51,1809,1812],{"x":1810,"y":1811,"style":549,"fill":56,"textAnchor":57},"135","120.78","vertical accuracy",[51,1814,1816],{"x":1810,"y":1815,"style":103,"fill":72,"textAnchor":57},"148.78","± 0.05 m",[51,1818,1822],{"x":1819,"y":1820,"style":1821,"fill":64,"textAnchor":57},"266","136","text-anchor:middle;font-size:20.0px;font-family:sans-serif","×",[44,1824],{"x":1825,"y":1806,"width":1826,"height":70,"rx":527,"fill":543,"stroke":64,"style":544},"284","200",[51,1828,1830],{"x":1829,"y":1811,"style":549,"fill":56,"textAnchor":57},"384","area",[51,1832,1833],{"x":1829,"y":1815,"style":103,"fill":72,"textAnchor":57},"2 ha",[51,1835,190],{"x":1836,"y":1820,"style":1821,"fill":64,"textAnchor":57},"504",[44,1838],{"x":1839,"y":1806,"width":1840,"height":70,"rx":527,"fill":78,"stroke":79,"style":544},"522","214",[51,1842,1845],{"x":1843,"y":1844,"style":549,"fill":79,"textAnchor":57},"629","106.78","systematic bound",[51,1847,1849],{"x":1843,"y":1848,"style":103,"fill":72,"textAnchor":57},"134.78","± 1,000 m³",[51,1851,1853],{"x":1843,"y":1852,"style":103,"fill":64,"textAnchor":57},"162.78","report 12,800 ± 1,000",[141,1855,1857],{"className":143,"code":1856,"language":145,"meta":146,"style":146},"area_m2 = valid.sum() * cell_area\nvertical_accuracy = 0.05\nbound = vertical_accuracy * area_m2\nprint(f\"cut {round(cut, -2):,.0f} m³ ± {round(bound, -2):,.0f} m³ (systematic bound)\")\n",[136,1858,1859,1873,1883,1898],{"__ignoreMap":146},[150,1860,1861,1864,1866,1869,1871],{"class":67,"line":152},[150,1862,1863],{"class":159},"area_m2 ",[150,1865,190],{"class":155},[150,1867,1868],{"class":159}," valid.sum() ",[150,1870,821],{"class":155},[150,1872,889],{"class":159},[150,1874,1875,1878,1880],{"class":67,"line":163},[150,1876,1877],{"class":159},"vertical_accuracy ",[150,1879,190],{"class":155},[150,1881,1882],{"class":256}," 0.05\n",[150,1884,1885,1888,1890,1893,1895],{"class":67,"line":177},[150,1886,1887],{"class":159},"bound ",[150,1889,190],{"class":155},[150,1891,1892],{"class":159}," vertical_accuracy ",[150,1894,821],{"class":155},[150,1896,1897],{"class":159}," area_m2\n",[150,1899,1900,1902,1904,1906,1908,1911,1914,1916,1918,1921,1923,1925,1928,1930,1933,1935,1937,1939,1941,1943,1946],{"class":67,"line":184},[150,1901,925],{"class":256},[150,1903,428],{"class":159},[150,1905,431],{"class":155},[150,1907,932],{"class":196},[150,1909,1910],{"class":256},"{round",[150,1912,1913],{"class":159},"(cut, ",[150,1915,280],{"class":155},[150,1917,1314],{"class":256},[150,1919,1920],{"class":159},")",[150,1922,478],{"class":155},[150,1924,446],{"class":256},[150,1926,1927],{"class":196}," m³ ± ",[150,1929,1910],{"class":256},[150,1931,1932],{"class":159},"(bound, ",[150,1934,280],{"class":155},[150,1936,1314],{"class":256},[150,1938,1920],{"class":159},[150,1940,478],{"class":155},[150,1942,446],{"class":256},[150,1944,1945],{"class":196}," m³ (systematic bound)\"",[150,1947,211],{"class":159},[14,1949,1950,1952],{},[489,1951,491],{}," Multiplying the vertical accuracy by the area gives the volume error if the whole surface were offset by that amount — a conservative bound for systematic error. Random errors partly cancel and contribute less, but systematic ones do not. Rounding the reported volume to match — hundreds of cubic metres for a 2-hectare site surveyed to 5 cm — prevents a spreadsheet from implying precision nobody has. Always state the surfaces, dates, boundary and method alongside the number.",[106,1954,1956],{"id":1955},"qgis-version-compatibility","QGIS version compatibility",[14,1958,1959,1961,1962,200,1965,1968,1969,1972,1973,1976],{},[136,1960,138],{}," has been available since QGIS 3.4 and works unchanged on 3.34 LTR, 3.40 LTR and QGIS 4, with outputs ",[136,1963,1964],{},"VOLUME",[136,1966,1967],{},"AREA"," and ",[136,1970,1971],{},"PIXEL_COUNT",". The NumPy approach is version-independent. ",[136,1974,1975],{},"gdal:cliprasterbymasklayer"," parameters are stable across these releases.",[106,1978,1980],{"id":1979},"troubleshooting","Troubleshooting",[111,1982,1983,1989,1995,2001],{},[114,1984,1985,1988],{},[489,1986,1987],{},"Volumes are far too large."," NoData was counted as a real value; check the NoData value and NaN handling.",[114,1990,1991,1994],{},[489,1992,1993],{},"Cut and fill do not match a manual check."," The surfaces are not on the same grid; align them first.",[114,1996,1997,2000],{},[489,1998,1999],{},"Stockpile volume changes with the outline."," The base level depends on the edge; keep the same outline across surveys.",[114,2002,2003,2006],{},[489,2004,2005],{},"Monthly changes look noisy everywhere."," Apply a noise threshold and check for systematic offsets on stable ground.",[106,2008,2010],{"id":2009},"conclusion","Conclusion",[14,2012,2013,2014,2016],{},"Clip every surface to the site boundary, use ",[136,2015,138],{}," for flat design levels and the cell-by-cell difference on a shared grid for design surfaces, build a base from the pile edge for stockpiles, threshold changes by survey accuracy when comparing dates, and report volumes rounded to the precision the surfaces support.",[106,2018,2020],{"id":2019},"frequently-asked-questions","Frequently Asked Questions",[14,2022,2023,2026],{},[489,2024,2025],{},"Can I compute volumes from contour lines?","\nInterpolate them to a raster first, for example with TIN interpolation, then use the same methods.",[14,2028,2029,2032],{},[489,2030,2031],{},"Does cell size affect the result?","\nSlightly; finer cells follow slopes more faithfully. The surface's accuracy matters more than its resolution.",[14,2034,2035,2038],{},[489,2036,2037],{},"How do I account for soil bulking?","\nMultiply cut volumes by a bulking factor for the material; that is an engineering input, not a GIS one.",[14,2040,2041,2044],{},[489,2042,2043],{},"Can I get volumes per zone of a site?","\nRun the calculation per polygon, or use zonal statistics on the difference raster with a sum statistic multiplied by cell area.",[106,2046,2048],{"id":2047},"related","Related",[111,2050,2051,2056,2061,2066,2071],{},[114,2052,2053,2055],{},[21,2054,24],{"href":23}," — the guide this recipe belongs to",[114,2057,2058],{},[21,2059,2060],{"href":1592},"Build a TIN Interpolation in PyQGIS",[114,2062,2063],{},[21,2064,2065],{"href":122},"Create a DEM from a Point Cloud in PyQGIS",[114,2067,2068],{},[21,2069,2070],{"href":1010},"Resample and Align Rasters in PyQGIS",[114,2072,2073],{},[21,2074,2076],{"href":2075},"\u002Fspatial-data-processing-automation\u002Fraster-analysis-workflows\u002Fclip-raster-by-mask-layer-pyqgis\u002F","Clip a Raster by a Mask Layer in PyQGIS",[2078,2079,2080],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}",{"title":146,"searchDepth":163,"depth":163,"links":2082},[2083,2084,2085,2086,2087,2088,2089,2090,2091,2092,2093,2094],{"id":108,"depth":163,"text":109},{"id":130,"depth":163,"text":131},{"id":499,"depth":163,"text":500},{"id":1019,"depth":163,"text":1020},{"id":1225,"depth":163,"text":1226},{"id":1597,"depth":163,"text":1598},{"id":1781,"depth":163,"text":1782},{"id":1955,"depth":163,"text":1956},{"id":1979,"depth":163,"text":1980},{"id":2009,"depth":163,"text":2010},{"id":2019,"depth":163,"text":2020},{"id":2047,"depth":163,"text":2048},"Compute earthwork volumes from elevation rasters in PyQGIS — volume above or below a design level with native:rastersurfacevolume, cut and fill between existing and design surfaces with NumPy, stockpile volumes from survey DEMs, clipping to a site boundary and reporting with honest precision.","md",{"slug":2098,"type":2099,"breadcrumb":2100,"datePublished":2101,"dateModified":2101},"calculate-cut-and-fill-volume-pyqgis","article","Calculate Cut and Fill Volume","2026-10-02","\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fcalculate-cut-and-fill-volume-pyqgis",{"title":5,"description":2095},"spatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fcalculate-cut-and-fill-volume-pyqgis\u002Findex","zHp4jGvVw1Q33nqo_HJfhB4U4I3hmS9FV5fX0egsmdQ",1790966265012]