Resources Numerical accuracy Calculation policy

Earthwork progress from repeat surveys

Compares dated surveys with the original ground and the design to give volumes done and remaining, percent complete, over-cut and over-fill, stockpiles, and a forecast finish date.

How the precision is kept

A grid of cells is laid over the common area and each surface is sampled at cell centers in 64-bit; volumes are sums over cells. The cell size and the tolerance set the accuracy. The forecast is a straight line fitted to the last surveys.

Calculations under this policy

3 calculations in 1 source file. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.

progress.js CivilCraft 0.8.1 64-bit 3 calculations Reviewed

Tests bin/test-civilcraft-progress.mjs

Volumes done, remaining and percent complete

64-bit

For each survey: cut and fill done, what remains, over-cut, over-fill, stockpiles, digs and percent complete.

cut done = Σ clamp(E − A, 0, E − D)·A_cell; fill done = Σ clamp(A − E, 0, D − E)·A_cell; percent = 100 × (cut done + fill done) ÷ planned

The cell count is round(width ÷ cell) each way and the cells are stretched to fill the bounds exactly, so the cell used can differ slightly from the one asked for. Centers come from whole-number indexes. Every volume is a running sum over cells; the tolerance 0.05 is absolute in project units.

  • progress.js
  • lines 45 to 104
  • CivilCraft 0.8.1
export function progressGrid({ existing, design, surveys = [], region = null, cell = null, tol = 0.05, maxCells = 4e6, schedule = null } = {}) {
  const bs = [existing.stats().bounds, design.stats().bounds];
  let b = { minX: Math.max(bs[0].minX, bs[1].minX), minY: Math.max(bs[0].minY, bs[1].minY), maxX: Math.min(bs[0].maxX, bs[1].maxX), maxY: Math.min(bs[0].maxY, bs[1].maxY) };
  const regions = region ? (Array.isArray(region[0]) ? region : [region]).filter(Boolean) : null;
  if (regions?.length) {
    const r = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
    for (const poly of regions) for (const p of poly) { r.minX = Math.min(r.minX, p.x); r.minY = Math.min(r.minY, p.y); r.maxX = Math.max(r.maxX, p.x); r.maxY = Math.max(r.maxY, p.y); }
    b = { minX: Math.max(b.minX, r.minX), minY: Math.max(b.minY, r.minY), maxX: Math.min(b.maxX, r.maxX), maxY: Math.min(b.maxY, r.maxY) };
  }
  const W = b.maxX - b.minX, H = b.maxY - b.minY;
  const out = { cell: 0, area: 0, cells: 0, planned: { cut: 0, fill: 0, total: 0 }, series: [], map: { cells: [] }, forecast: null };
  if (!(W > 0 && H > 0)) return out;
  let c = cell > 0 ? cell : Math.sqrt((W * H) / 40000);
  while ((W / c) * (H / c) > maxCells) c *= 2;
  out.cell = c;
  const A = c * c, inPoly = (poly, x, y) => { let k = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const p = poly[i], q = poly[j]; if ((p.y > y) !== (q.y > y) && x < ((q.x - p.x) * (y - p.y)) / (q.y - p.y) + p.x) k = !k; } return k; };
  const nx = Math.max(1, Math.round(W / c)), ny = Math.max(1, Math.round(H / c)), cx = W / nx, cy = H / ny, Acell = cx * cy;
  const base = [];
  for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) {
    const x = b.minX + (i + 0.5) * cx, y = b.minY + (j + 0.5) * cy;
    if (regions && !regions.some((poly) => inPoly(poly, x, y))) continue;
    const e = existing.elevationAt(x, y), d = design.elevationAt(x, y);
    if (e === null || d === null || e === undefined || d === undefined) continue;
    base.push({ x, y, e, d });
  }
  out.cells = base.length; out.area = base.length * Acell;
  for (const q of base) { const p = q.d - q.e; if (p < -tol) out.planned.cut += -p * Acell; else if (p > tol) out.planned.fill += p * Acell; }
  out.planned.total = out.planned.cut + out.planned.fill;
  const sorted = [...surveys].sort((p, q) => ms(p.date) - ms(q.date));
  for (const sv of sorted) {
    const row = { date: typeof sv.date === 'string' ? sv.date : new Date(ms(sv.date)).toISOString().slice(0, 10), label: sv.label || '', cutDone: 0, fillDone: 0, overCut: 0, overFill: 0, stockpile: 0, dig: 0, within: 0, covered: 0, remainingCut: 0, remainingFill: 0, pct: 0 };
    let n = 0, within = 0;
    const cellsOut = [];
    for (const q of base) {
      const a = sv.surface.elevationAt(q.x, q.y);
      if (a === null || a === undefined || !Number.isFinite(a)) continue;
      n++;
      const p = q.d - q.e, now = a - q.d;
      if (Math.abs(now) <= tol) within++;
      if (p < -tol) {   // cut cell
        row.cutDone += Math.min(Math.max(q.e - a, 0), q.e - q.d) * Acell;
        if (a < q.d - tol) row.overCut += (q.d - a) * Acell;
      } else if (p > tol) {
        row.fillDone += Math.min(Math.max(a - q.e, 0), q.d - q.e) * Acell;
        if (a > q.d + tol) row.overFill += (a - q.d) * Acell;
      } else {
        if (a > q.e + tol) row.stockpile += (a - q.e) * Acell; else if (a < q.e - tol) row.dig += (q.e - a) * Acell;
      }
      cellsOut.push({ x: q.x, y: q.y, plan: p, now, dz: a - q.e });
    }
    row.covered = base.length ? n / base.length : 0; row.within = n ? within / n : 0;
    row.remainingCut = Math.max(0, out.planned.cut - row.cutDone); row.remainingFill = Math.max(0, out.planned.fill - row.fillDone);
    row.pct = out.planned.total > 0 ? (100 * (row.cutDone + row.fillDone)) / out.planned.total : 0;
    const sch = scheduleAt(schedule, row.date); if (sch !== null) { row.scheduled = sch; row.ahead = row.pct - sch; }
    out.series.push(row);
    out.map.cells = cellsOut;
  }
  out.forecast = forecast(out.series);
  return out;
}

Forecast finish date

64-bit

The date the work reaches 100 percent at the pace of the last surveys.

slope = Σ (t − t̄)(p − p̄) ÷ Σ (t − t̄)²; t_end = t_last + (100 − p_last) ÷ slope

Times are milliseconds since 1970, about 1.8e12, and the mean time is subtracted before the sums, so the fit works on small differences. No forecast is given when the slope is zero or negative. The date is cut to a whole day.

  • progress.js
  • lines 33 to 43
  • CivilCraft 0.8.1
export function forecast(series, { last = 3 } = {}) {
  const pts = series.filter((s) => Number.isFinite(s.pct)).slice(-last).map((s) => ({ t: ms(s.date), p: s.pct }));
  if (pts.length < 2) return null;
  const n = pts.length, mt = pts.reduce((a, q) => a + q.t, 0) / n, mp = pts.reduce((a, q) => a + q.p, 0) / n;
  let num = 0, den = 0; for (const q of pts) { num += (q.t - mt) * (q.p - mp); den += (q.t - mt) ** 2; }
  if (!(den > 0)) return null;
  const slope = num / den;   // per ms
  if (!(slope > 0)) return null;
  const tEnd = pts[n - 1].t + ((100 - pts[n - 1].p) / slope);
  return { date: new Date(Math.max(tEnd, pts[n - 1].t)).toISOString().slice(0, 10), perDay: slope * DAY, estimate: true };
}

Scheduled percent on a date

64-bit

The planned percent complete on a date, between the schedule's points.

p = p₀ + (p₁ − p₀) × (t − t₀) ÷ (t₁ − t₀)

Linear interpolation on millisecond times; before the first point and after the last the end values are used.

  • progress.js
  • lines 25 to 31
  • CivilCraft 0.8.1
export function scheduleAt(schedule, date) {
  const s = (schedule || []).map((r) => ({ t: ms(r.date), pct: r.pct })).sort((a, b) => a.t - b.t), t = ms(date);
  if (!s.length) return null;
  if (t <= s[0].t) return s[0].pct; if (t >= s[s.length - 1].t) return s[s.length - 1].pct;
  for (let i = 1; i < s.length; i++) if (t <= s[i].t) return s[i - 1].pct + ((s[i].pct - s[i - 1].pct) * (t - s[i - 1].t)) / (s[i].t - s[i - 1].t);
  return null;
}

In detail

Method
Grid method per cell: planned change = design − existing; done = clamped change toward the design; percent = done ÷ planned by volume. Forecast: least-squares line through the last 3 percent values.
Number types
64-bit numbers; dates as milliseconds.
Tolerances
Tolerance 0.05 project units by default (the same number for feet and meters): cells with a planned change within it are level, and as-built within it of the design counts as within. Default cell = √(width × height ÷ 40000), doubled while over 4,000,000 cells.
Limits
Cells are counted whole by their centers. The forecast is an estimate and is given only when the fitted line rises.

Approach: 64-bit, stated limit

The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.

Every policy that takes this approach