Resources Numerical accuracy Calculation policy

Replacement schedule and cost over the building's life

Lists when each part of the building is replaced over a study period, what that costs with inflation and discounting, and how much embodied carbon the replacements add.

How the precision is kept

Replacement years are k × life, a multiplication and not a running sum, and costs are 64-bit products and powers. The quantities taken from the model are rounded to 0.01 of their unit before they are costed, and an installed item's age to 0.1 year. The CSV rounds money to 0.01 and carbon to 0.1 kg. The service lives are planning figures the file says are for the person to review.

Open item. Carry the model's quantities unrounded into the cost and carbon sums and round only in the table and the CSV.

Calculations under this policy

6 calculations in 2 source files. 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.

lifecycle.js BimCraft 0.8.1 64-bit 2 calculations Reviewed

Tests tools/bimcraft-test/lifecycle-test.mjs

Replacement years, cost and present value

64-bit

Every replacement in the study period with its cost, and the totals by year and by part.

t = t₀ + k × life; cost = q × c × (1 + i)^t; PV = cost ÷ (1 + r)^t

The year of each replacement is firstYear + k × life, computed fresh for each k. Inflation and discounting use that unrounded year while the year shown and used to bin the cost is Math.round of it. Totals are plain sums over the parts.

  • lifecycle.js
  • lines 40 to 68
  • BimCraft 0.8.1
export function lifecycleRun(components, { years = 50, discount = 0, inflation = 0 } = {}) {
  const N = Math.max(1, Math.floor(years)), events = [], byYear = Array.from({ length: N + 1 }, (_, y) => ({ year: y, cost: 0, pv: 0, cum: 0, carbon: 0 }));
  const rows = [];
  for (const c of components) {
    const life = Number(c.life), qty = Number(c.qty) || 0, unitCost = Number(c.unitCost);
    const uc = Number(c.unitCarbon), hasC = c.unitCarbon !== null && c.unitCarbon !== undefined && c.unitCarbon !== '' && Number.isFinite(uc);
    const row = { key: c.key, name: c.name, qty, unit: c.unit, life, unitCarbon: hasC ? uc : null, carbon: 0, unitCost: Number.isFinite(unitCost) && c.unitCost !== '' && c.unitCost !== null && c.unitCost !== undefined ? unitCost : null, years: [], cost: 0, pv: 0 };
    if (life > 0 && qty > 0) {
      for (let k = 1; ; k++) {
        let y = (c.firstYear || 0) + k * life;
        if (y >= N - 1e-9) break;
        if (y < 0) y = 0;   // already past its life: replaced now (year 0)
        const yr = Math.round(y), nominal = row.unitCost === null ? null : qty * row.unitCost * Math.pow(1 + inflation, y);
        row.years.push(yr);
        const kg = hasC ? qty * uc : null;   // embodied carbon of the new part (kg CO2e): what the model's carbon factors give per unit
        events.push({ year: yr, key: c.key, name: c.name, qty, cost: nominal, carbon: kg });
        if (kg !== null) { byYear[Math.min(yr, N)].carbon += kg; row.carbon += kg; }
        if (nominal !== null) { byYear[Math.min(yr, N)].cost += nominal; byYear[Math.min(yr, N)].pv += nominal / Math.pow(1 + discount, y); row.cost += nominal; row.pv += nominal / Math.pow(1 + discount, y); }
      }
    }
    rows.push(row);
  }
  events.sort((a, b) => a.year - b.year || String(a.key).localeCompare(String(b.key)));
  let cum = 0;
  for (const y of byYear) { cum += y.pv; y.cum = cum; }
  const peak = byYear.reduce((m, y) => (y.cost > m.cost ? y : m), { year: null, cost: 0 });
  return { years: N, events, byYear, rows, totalCarbon: rows.reduce((s, r) => s + r.carbon, 0), nocarbon: rows.filter((r) => r.unitCarbon === null && r.years.length).map((r) => r.key), total: rows.reduce((s, r) => s + r.cost, 0), totalPv: rows.reduce((s, r) => s + r.pv, 0), count: events.length, peak: peak.cost > 0 ? peak : null,
    uncosted: rows.filter((r) => r.unitCost === null && r.years.length).map((r) => r.key) };
}

Replacement schedule as CSV

64-bit

The schedule and the year-by-year costs written as text.

money = round(x × 100) ÷ 100; carbon = round(x × 10) ÷ 10

Costs and present values are rounded to 0.01 and carbon to 0.1 kg with Math.round as they are written; the quantities and lives are written as held.

  • lifecycle.js
  • lines 70 to 77
  • BimCraft 0.8.1
export function lifecycleCsv(res) {
  const q = (v) => '"' + String(v).replace(/"/g, '""') + '"';
  const out = [['component', 'quantity', 'unit', 'life years', 'cost per unit', 'replaced in years', 'cost', 'present value', 'carbon per unit kgCO2e', 'carbon kgCO2e'].map(q).join(',')];
  for (const r of res.rows) out.push([r.name, r.qty, r.unit, r.life, r.unitCost ?? '', r.years.join(' '), Math.round(r.cost * 100) / 100, Math.round(r.pv * 100) / 100, r.unitCarbon ?? '', Math.round(r.carbon * 10) / 10].map(q).join(','));
  out.push('', ['year', 'cost', 'present value', 'running present value'].map(q).join(','));
  for (const y of res.byYear) if (y.cost > 0) out.push([y.year, Math.round(y.cost * 100) / 100, Math.round(y.pv * 100) / 100, Math.round(y.cum * 100) / 100].map(q).join(','));
  return out.join('\n');
}
lifecycle-model.js BimCraft 0.8.1 64-bit 4 calculations Reviewed

Tests tools/bimcraft-test/lifecycle-test.mjs

Quantities of the building's parts

64-bit

Window area, door counts, exterior wall area, room finishes, roof area and stairs taken from the model.

wall = Σ perimeter ÷ 1000 × height − openings; window = w ÷ 1000 × h ÷ 1000; quantity = round(q × 100) ÷ 100

Each quantity is rounded to 0.01 before it is returned and costed. Exterior wall is the footprint perimeter times the story height less the outside openings, floored at zero; areas come from millimeters by dividing by 1000 per side or by 1e6.

  • lifecycle-model.js
  • lines 22 to 44
  • BimCraft 0.8.1
export function lifecycleParts(ed, { mep = true, today = null } = {}) {
  const { w, levels, rooms, doors, stairs, notes } = modelLinks(ed);
  const at = attachDoors(rooms, doors), byId = new Map(doors.map((d) => [d.id, d]));
  const area = (d) => (d.w / 1000) * (d.h / 1000);
  let winArea = 0, extDoors = 0, intDoors = 0, extOpen = 0;
  for (const d of doors) if (d.kind === 'window') { winArea += area(d); extOpen += area(d); }
  for (const o of at) { const d = byId.get(o.id); if (d.kind !== 'door') continue; if (o.rooms[1] === null) { extDoors++; extOpen += area(d); } else intDoors++; }
  const ac = acousticsRooms(ed, {}), sum = (name) => ac.reduce((s, r) => s + r.surfaces.filter((x) => x.name === name || x.name.startsWith(name + ':')).reduce((q, x) => q + x.area_m2, 0), 0);
  // exterior wall: the footprint's perimeter × the storey heights, less the openings in outside walls
  let extWall = 0, roof = 0;
  levels.forEach((lv, i) => {
    const outs = (storeyLoops(w, lv.id).outlines || []).filter((o) => o.poly && o.poly.length > 2), next = levels[i + 1], h = (next ? next.elevation - lv.elevation : lv.height) / 1000;
    for (const o of outs) { let per = 0; for (let k = 0; k < o.poly.length; k++) { const a = o.poly[k], b = o.poly[(k + 1) % o.poly.length]; per += Math.hypot(b[0] - a[0], b[1] - a[1]); } extWall += (per / 1000) * h; }
    if (!next) roof = outs.reduce((s, o) => s + Math.abs(o.area) / 1e6, 0);
  });
  extWall = Math.max(0, extWall - extOpen);
  const r1 = (v) => Math.round(v * 100) / 100;
  const part = (key, name, qty) => ({ key, name, qty: r1(qty), unit: LIVES[key].unit, life: LIVES[key].years });
  const building = [part('windows', 'Windows', winArea), part('door-exterior', 'Exterior doors', extDoors), part('door-interior', 'Interior doors', intDoors), part('wall-exterior', 'Exterior wall finish', extWall),
    part('wall-paint', 'Interior wall paint', sum('walls')), part('floor-finish', 'Floor finish', sum('floor')), part('ceiling-finish', 'Ceiling finish', sum('ceiling')), part('roof', 'Roof covering', roof), part('stairs', 'Stairs', stairs.length)].filter((p) => p.qty > 0);
  const svc = mep ? lifecycleServices(ed, { today }) : { parts: [], notes: [] };
  return { parts: [...building, ...svc.parts], notes: [...notes, ...svc.notes] };
}

Age of an installed item

64-bit

Years between an item's install date and today.

age = (today − installed) ÷ (365.25 days)

The difference of two UTC midnights in milliseconds divided by 365.25 × 86 400 000. A future or unreadable date gives zero. The age is later rounded to 0.1 year where it sets the first replacement.

  • lifecycle-model.js
  • lines 48 to 49
  • BimCraft 0.8.1
/** years between a 'YYYY-MM-DD' date and today (0 when unreadable or in the future) */
const ageOf = (day, today) => { if (!/^\d{4}-\d{2}-\d{2}$/.test(String(day || ''))) return 0; const a = Date.parse(day + 'T00:00:00Z'), b = Date.parse((today || new Date().toISOString().slice(0, 10)) + 'T00:00:00Z'); return Number.isFinite(a) && b > a ? (b - a) / YEAR_MS : 0; };

Quantities of the building services

64-bit

Equipment by class, meters of pipe and duct, and an estimate of the wiring.

pipe = round(Σ |pᵢ − pᵢ₋₁| ÷ 1000, 2); wiring = round(Σ nearest distances ÷ 1000 × 1.25, 2)

Run lengths are 64-bit sums of three-dimensional distances between a run's points, divided by 1000 and rounded to 0.01 m. Wiring adds plan distances along a greedy nearest-first path and multiplies by 1.25; the result is flagged as an estimate.

  • lifecycle-model.js
  • lines 51 to 100
  • BimCraft 0.8.1
/**
 * 0.8 EG.45 (bim-sim_PART_3): the building services as parts to schedule.
 *   lifecycleServices(ed, { today }) → { parts: [{ key, name, qty, unit, life, firstYear?, group: 'services', classes?, guids? }], notes }
 *   - equipment (pumps, fans, boilers, valves, terminals, fixtures, outlets, switches, boards …, the classes the Assets tab tracks): grouped by IFC class with the class's planning life;
 *     an item whose own Pset_ServiceLife says otherwise is its own part with that life, and one with an install date starts at its age (so an old pump is due sooner, an overdue one now)
 *   - pipes and ducts: the metres of every drawn run (mep-model readRuns); wiring: an estimate, the straight plan distance from the board along each circuit's devices in the
 *     nearest-first order, × 1.25 for the routing (a labelled estimate: the model draws no cable)
 */
export function lifecycleServices(ed, { today = null } = {}) {
  const parts = [], notes = [];
  const group = 'services';
  let assets = [];
  try { assets = readAssets(ed.W, ed.api, ed.mid, today ? { today } : {}); } catch (e) { assets = []; }
  const byClass = new Map();
  for (const a of assets) {
    if (!EQUIPMENT_LIVES[a.cls] && !a.data?.life) continue;
    const own = Number(a.data?.life) > 0 ? Number(a.data.life) : 0;
    const age = ageOf(a.data?.installed, today);
    if (own || age) {
      parts.push({ key: 'asset:' + a.guid, name: a.tag || a.name || CLASS_NAME(a.cls), qty: 1, unit: 'each', life: own || EQUIPMENT_LIVES[a.cls] || DEFAULT_EQUIPMENT_LIFE, firstYear: -Math.round(age * 10) / 10, group, cls: a.cls, guids: [a.guid] });
    } else {
      const g = byClass.get(a.cls) || { n: 0, guids: [] }; g.n++; g.guids.push(a.guid); byClass.set(a.cls, g);
    }
  }
  for (const [cls, g] of byClass) parts.push({ key: 'eq:' + cls, name: CLASS_NAME(cls), qty: g.n, unit: 'each', life: EQUIPMENT_LIVES[cls] || DEFAULT_EQUIPMENT_LIFE, group, cls, guids: g.guids });
  const w = new ModelWriter(ed.W, ed.api, ed.mid);
  let runs = [];
  try { runs = readRuns(w); } catch (e) { runs = []; }
  const len = (r) => { let s = 0; for (let i = 1; i < r.points.length; i++) s += Math.hypot(r.points[i][0] - r.points[i - 1][0], r.points[i][1] - r.points[i - 1][1], (r.points[i][2] || 0) - (r.points[i - 1][2] || 0)); return s / 1000; };
  for (const kind of ['pipe', 'duct']) {
    const mine = runs.filter((r) => r.kind === kind);
    if (mine.length) parts.push({ key: kind, name: kind, qty: Math.round(mine.reduce((s, r) => s + len(r), 0) * 100) / 100, unit: 'm', life: LIVES[kind].years, group, guids: mine.map((r) => r.guid) });
  }
  let devices = [];
  try { devices = readDevices(w); } catch (e) { devices = []; }
  const circuits = new Map();
  for (const d of devices) if (d.circuit) { const l = circuits.get(d.circuit) || []; l.push(d); circuits.set(d.circuit, l); }
  let cable = 0;
  for (const list of circuits.values()) {
    const left = list.slice(), board = left.findIndex((d) => /^board/.test(d.kind));
    let cur = board >= 0 ? left.splice(board, 1)[0] : left.shift();
    while (left.length && cur) {
      let bi = 0, bd = Infinity;
      left.forEach((d, i) => { const dd = Math.hypot(d.at[0] - cur.at[0], d.at[1] - cur.at[1]); if (dd < bd) { bd = dd; bi = i; } });
      cable += bd / 1000; cur = left.splice(bi, 1)[0];
    }
  }
  if (cable > 0) { parts.push({ key: 'wiring', name: 'wiring', qty: Math.round(cable * 1.25 * 100) / 100, unit: 'm', life: LIVES.wiring.years, group, estimate: true }); notes.push({ key: 'eg45.note.wiring' }); }
  return { parts, notes };
}

Carbon per unit of each part

64-bit

The embodied carbon of one unit of a part, from the model's carbon totals.

kg per unit = Σ carbon of the part's elements ÷ quantity

The carbon of the part's elements is summed and divided by the part's quantity, which is the value already rounded to 0.01. A sum that is not greater than zero is reported as no value.

  • lifecycle-model.js
  • lines 109 to 126
  • BimCraft 0.8.1
export function lifecycleCarbon(ed, parts, { geometry = true } = {}) {
  const w = new ModelWriter(ed.W, ed.api, ed.mid), units = new Map(), notes = [];
  let r;
  try { r = calcCarbonModel(w, { geometry }); } catch (e) { r = { ok: false }; }
  if (!r.ok) { for (const p of parts) units.set(p.key, null); notes.push({ key: 'eg45.note.nocarbon' }); return { units, notes, total_kg: 0 }; }
  const rows = r.res.rows;
  const doorQty = parts.filter((p) => p.key.startsWith('door-')).reduce((s, p) => s + p.qty, 0);
  for (const p of parts) {
    let kg = null;
    const sumBy = (f) => rows.filter(f).reduce((s, x) => s + x.total, 0);
    if (p.guids && p.cls) kg = sumBy((x) => p.guids.includes(x.guid));
    else if (p.key.startsWith('door-')) kg = doorQty > 0 ? sumBy((x) => x.cls === 'IFCDOOR') / doorQty : null;
    else if (CARBON_CLASSES[p.key]) kg = sumBy((x) => CARBON_CLASSES[p.key].includes(x.cls));
    if (kg !== null && !(kg > 0)) kg = null;
    units.set(p.key, kg === null || !(p.qty > 0) ? null : p.key.startsWith('door-') ? kg : kg / p.qty);
  }
  return { units, notes, total_kg: r.res.totals.total };
}

In detail

Method
Replacement at firstYear + k × life while before the end of the study; nominal cost = quantity × unit cost × (1 + i)^t; present value = nominal ÷ (1 + r)^t.
Number types
64-bit numbers.
Tolerances
A replacement falling within 1e-9 year of the end of the study is left out. Quantities rounded to 0.01; ages to 0.1 year.
Checked by
tools/bimcraft-test/lifecycle-test.mjs calls lifecycleRun, lifecycleCsv, lifecycleParts and lifecycleServices in Node.
Limits
Roof area is the top level's footprint, flat. Wiring length is an estimate: straight plan distances between a circuit's devices in nearest-first order, times 1.25. A year is 365.25 days.

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