Resources Numerical accuracy Calculation policy

Bid packages and leveling

Our own cost of each bid package, each subcontractor's bid leveled with plugs for the scope it leaves out, the low bid, and the base bid with the chosen bids in it.

How the precision is kept

Every money value here is a whole number of cents: cleanBid accepts a bid amount only as an integer from 0 to 1e13, and the estimate's row totals are whole cents. Costs, plugs, leveled amounts and differences are sums and differences of those integers, which 64-bit numbers hold without error below 2^53. The package file for a subcontractor carries quantities rounded to 4 decimals and no prices.

Calculations under this policy

5 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.

level.js TakeoffCraft 0.8.1 64-bit 5 calculations Reviewed

Tests bin/test-takeoffcraft.mjs

Our cost of each item

64-bit

The sum of an item's estimate rows: the item, its second conditions and its assembly parts.

cost = Σ row totals

Whole cents added per item in row order. Rows with a formula error are left out.

  • level.js
  • lines 123 to 128
  • TakeoffCraft 0.8.1
/** Our own cost of each item: the sum of its estimate rows (item, conditions, assembly parts), errors left out. */
function itemCosts(est) {
  const m = new Map();
  for (const r of est.rows) if (!r.error) m.set(r.itemId, (m.get(r.itemId) || 0) + r.total);
  return m;
}

Package scope and cost

64-bit

The items of each CSI division with their quantity, unit and cost, and the package's cost.

package cost = Σ item costs

The package cost is a sum of whole cents. Each scope line carries the item row's quantity as the unrounded 64-bit number the estimate holds, or none when the row has an error.

  • level.js
  • lines 130 to 145
  • TakeoffCraft 0.8.1
export function bidPackages(state, est, { title = (k) => k } = {}) {
  const cost = itemCosts(est), qrow = new Map(est.rows.filter((r) => r.kind === 'item').map((r) => [r.itemId, r]));
  const sheets = new Map();
  for (const m of state.marks) { if (!sheets.has(m.item)) sheets.set(m.item, new Set()); sheets.get(m.item).add(m.sheet); }
  const by = new Map();
  for (const it of state.items) {
    const k = pkgOf(it);
    const p = by.get(k) || { key: k, title: title(k), items: [], sheets: new Set(), cost: 0 };
    const r = qrow.get(it.id);
    p.items.push({ itemId: it.id, name: it.name, csi: it.csi || '', qty: r && !r.error ? r.qty : null, unit: r?.unit || '', cost: cost.get(it.id) || 0, sheets: [...(sheets.get(it.id) || [])] });
    p.cost += cost.get(it.id) || 0;
    for (const s of sheets.get(it.id) || []) p.sheets.add(s);
    by.set(k, p);
  }
  return [...by.values()].sort((a, b) => (a.key || '99').localeCompare(b.key || '99')).map((p) => ({ ...p, sheets: [...p.sheets] }));
}

Leveled bids and the low bid

64-bit

Each bid with plugs for what it leaves out, its difference from our cost, and which bid is low.

leveled = bid + Σ plugs; difference = leveled − our cost; low = smallest leveled

All terms are whole cents, so the sums and the comparison carry no rounding while they stay under 2^53. On a tie the first bid entered stays low, because the comparison is strictly less than.

  • level.js
  • lines 147 to 164
  • TakeoffCraft 0.8.1
export function leveling(state, est, opts = {}) {
  const pk = new Map(bidPackages(state, est, opts).map((p) => [p.key, p]));
  const out = [];
  for (const [key, pkg] of pk) {
    const bids = (state.bids || []).filter((b) => b.pkg === key);
    if (!bids.length) continue;
    const inPkg = new Map(pkg.items.map((x) => [x.itemId, x]));
    const rows = bids.map((bid) => {
      const plugs = bid.excl.filter((id) => inPkg.has(id)).map((id) => ({ itemId: id, name: inPkg.get(id).name, cost: inPkg.get(id).cost }));
      const plug = plugs.reduce((n, x) => n + x.cost, 0), leveled = bid.amount + plug;
      return { bid, plugs, plug, leveled, diff: leveled - pkg.cost };
    });
    let low = null;
    for (const r of rows) if (!low || r.leveled < low.leveled) low = r;
    out.push({ pkg, ours: pkg.cost, bids: rows, low: low.bid.id });
  }
  return out;
}

Package scope file

64-bit

The scope sent to a subcontractor: item, CSI number, quantity, unit and sheets, without prices.

quantity = round(q × 10000) ÷ 10000

The quantity is rounded to 4 decimals with Math.round on q × 10000, which rounds a half toward +∞ and makes no allowance for binary noise in the product. This is a different rounding from the estimate CSV in export.js.

  • level.js
  • lines 167 to 173
  • TakeoffCraft 0.8.1
/** A package's scope for a subcontractor: item, CSI number, quantity, unit, the sheets its marks are on. No prices. */
export function packageCsv(pkg, sheetLabel = (k) => k) {
  const lines = [[t('pkg.csv.title', { pkg: pkg.title })], ['item', 'csi', 'qty', 'unit', 'sheets'].map((k) => t('pkg.csv.' + k))];
  for (const x of pkg.items) lines.push([x.name, x.csi, x.qty === null ? '' : Math.round(x.qty * 10000) / 10000, x.unit, x.sheets.map(sheetLabel).join('; ')]);
  lines.push([], [t('pkg.csv.sheets'), pkg.sheets.map(sheetLabel).join('; ')]);
  return lines.map((l) => l.map(cell).join(',')).join('\r\n') + '\r\n';
}

Base bid with the chosen bids

64-bit

The estimate with each awarded package priced at its chosen bid instead of our own rates.

total = material + labor + Σ chosen bids

The awarded items' amounts are set to zero and one row per award carries the bid's whole cents; the totals are then added again from all rows. Items the bid leaves out keep our own cost. Crew days divide the hours by a literal 8, which the comment ties to CREW_DAY in model.js.

  • level.js
  • lines 188 to 217
  • TakeoffCraft 0.8.1
/** The base bid with the chosen bids in it (see the header). Without awards, est itself. */
export function applyAwards(state, est) {
  const awards = cleanAwards(state.awards, state.bids);
  if (!awards.length) return est;
  const byPkg = new Map(awards.map((a) => [a.pkg, state.bids.find((b) => b.id === a.bid)]));
  const items = new Map(state.items.map((i) => [i.id, i]));
  const gone = new Set();                                     // items whose own pricing the bid replaced
  for (const it of state.items) {
    const b = byPkg.get(pkgOf(it));
    if (b && !b.excl.includes(it.id)) gone.add(it.id);
  }
  const rows = est.rows.map((r) => {
    if (r.error || !gone.has(r.itemId)) return r;
    const bidder = byPkg.get(pkgOf(items.get(r.itemId))).bidder;
    const { work, ...rest } = r;                              // our rates stay (the table shows them); only the extensions drop out
    return { ...rest, matExt: 0, labExt: 0, total: 0, awarded: bidder, ownTotal: r.total };
  });
  let sub = 0;
  for (const [pkg, b] of [...byPkg].sort((x, y) => (x[0] || '99').localeCompare(y[0] || '99'))) {
    sub += b.amount;
    rows.push({ kind: 'subcontract', itemId: null, pkg, bidId: b.id, bidder: b.bidder, name: b.bidder, unit: 'LS', qty: 1, qtyW: 1, waste: 0, mat: 0, lab: 0, matExt: 0, labExt: 0, subExt: b.amount, total: b.amount, type: 'sub', marks: 0 });
  }
  let mat = 0, lab = 0, errors = 0, hours = 0, workerHours = 0;
  for (const r of rows) {
    if (r.error) { errors++; continue; }
    mat += r.matExt; lab += r.labExt;
    if (r.work) { hours += r.work.hours; workerHours += r.work.workerHours; }
  }
  return { ...est, rows, matTotal: mat, labTotal: lab, subTotal: sub, total: mat + lab + sub, errors, incomplete: errors > 0 || est.unscaled > 0, hours, workerHours, days: hours / 8 };   // 8 = model.CREW_DAY (level.js can't import model.js: model imports it)
}

In detail

Method
Package cost = Σ item costs. Leveled bid = bid amount + Σ our cost of the items the bid leaves out. Low bid = the smallest leveled amount.
Number types
Integer cents in 64-bit numbers; quantities and crew hours are 64-bit numbers.
Limits
The package CSV rounds quantities with Math.round(q × 10000) ÷ 10000, not with the rounding the estimate CSV uses.

Approach: 64-bit, direct

Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.

Every policy that takes this approach