Replacement years, cost and present value
64-bitEvery 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.
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) };
}