Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/__tests__/financial.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,46 @@ describe("performSensitivityAnalysis", () => {
expect(parameters.has("Degradation Rate")).toBe(true);
expect(parameters.has("Energy Output")).toBe(true);
});

it("discount rate sensitivity uses additive deltas, not multiplicative multipliers", () => {
const input10 = createDefaultFinancialInput(500, 80, { discount_rate: 0.1 });
const result = performSensitivityAnalysis(input10);
const drPoints = result.sensitivities.filter((s) => s.parameter === "Discount Rate");

// The "+1%" label means base (0.10) + 0.01 = 0.11, so effective multiplier ≈ 1.10
const plus1 = drPoints.find((s) => s.change === "+1%");
expect(plus1).toBeDefined();
expect(plus1!.multiplier).toBeCloseTo(0.11 / 0.1, 6);

// The "-2%" label means base (0.10) - 0.02 = 0.08, so effective multiplier = 0.80
const minus2 = drPoints.find((s) => s.change === "-2%");
expect(minus2).toBeDefined();
expect(minus2!.multiplier).toBeCloseTo(0.08 / 0.1, 6);

// The "+2%" label means base (0.10) + 0.02 = 0.12
const plus2 = drPoints.find((s) => s.change === "+2%");
expect(plus2).toBeDefined();
expect(plus2!.multiplier).toBeCloseTo(0.12 / 0.1, 6);

// Higher discount rate should reduce NPV
expect(minus2!.npv).toBeGreaterThan(plus1!.npv);
expect(plus1!.npv).toBeGreaterThan(plus2!.npv);
});

it("discount rate sensitivity is correct for the default 7% base rate too", () => {
const result = performSensitivityAnalysis(BASE_INPUT);
const drPoints = result.sensitivities.filter((s) => s.parameter === "Discount Rate");

// Base is 0.07; "+1%" means 0.07 + 0.01 = 0.08 → multiplier ≈ 0.08/0.07 ≈ 1.1429
const plus1 = drPoints.find((s) => s.change === "+1%");
expect(plus1).toBeDefined();
expect(plus1!.multiplier).toBeCloseTo(0.08 / 0.07, 4);

// "-2%" means 0.07 - 0.02 = 0.05 → multiplier ≈ 0.05/0.07 ≈ 0.7143
const minus2 = drPoints.find((s) => s.change === "-2%");
expect(minus2).toBeDefined();
expect(minus2!.multiplier).toBeCloseTo(0.05 / 0.07, 4);
});
});

describe("compareROI", () => {
Expand Down
82 changes: 47 additions & 35 deletions src/lib/financial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ function calculateIRR(cashFlows: number[], guess = 0.1): number {
let dnpv = 0;
for (let t = 0; t < cashFlows.length; t++) {
npv += cashFlows[t] / Math.pow(1 + rate, t);
dnpv += -t * cashFlows[t] / Math.pow(1 + rate, t + 1);
dnpv += (-t * cashFlows[t]) / Math.pow(1 + rate, t + 1);
}
if (Math.abs(npv) < tolerance) {
return round(rate, 4);
Expand All @@ -180,7 +180,7 @@ export function createDefaultFinancialInput(
_efficiencyPct: number,
partial?: Partial<FinancialInput>,
): FinancialInput {
const capacityFactor = 0.20;
const capacityFactor = 0.2;
const annualEnergy = capacityKw * 8760 * capacityFactor;
const installationCostPerKw = 1000;
const maintenancePerKwPerYear = 15;
Expand All @@ -190,24 +190,24 @@ export function createDefaultFinancialInput(
installation_cost: capacityKw * installationCostPerKw,
annual_maintenance_cost: capacityKw * maintenancePerKwPerYear,
annual_energy_output_kwh: annualEnergy,
electricity_price_per_kwh: 0.10,
electricity_price_per_kwh: 0.1,
degradation_rate: 0.005,
discount_rate: 0.07,
inflation_rate: 0.02,
project_lifetime_years: 25,
tax_incentives: capacityKw * installationCostPerKw * 0.30,
salvage_value: capacityKw * installationCostPerKw * 0.10,
tax_incentives: capacityKw * installationCostPerKw * 0.3,
salvage_value: capacityKw * installationCostPerKw * 0.1,
capacity_factor: capacityFactor,
};

const merged = { ...defaults, ...partial };

const finalInstallCost = merged.installation_cost;
if (partial?.tax_incentives === undefined) {
merged.tax_incentives = finalInstallCost * 0.30;
merged.tax_incentives = finalInstallCost * 0.3;
}
if (partial?.salvage_value === undefined) {
merged.salvage_value = finalInstallCost * 0.10;
merged.salvage_value = finalInstallCost * 0.1;
}

return merged;
Expand All @@ -218,7 +218,9 @@ export function calculateCostBenefit(input: FinancialInput): CostBenefitResult {
const operatingCashFlows = cashFlows.filter((cf) => cf.year > 0);

const totalRevenue = round(operatingCashFlows.reduce((sum, cf) => sum + cf.revenue, 0));
const totalMaintenance = round(operatingCashFlows.reduce((sum, cf) => sum + cf.maintenance_cost, 0));
const totalMaintenance = round(
operatingCashFlows.reduce((sum, cf) => sum + cf.maintenance_cost, 0),
);
const totalInstallation = input.installation_cost;
const totalOperating = totalMaintenance;
const totalCost = round(totalInstallation + totalOperating);
Expand Down Expand Up @@ -261,7 +263,7 @@ export function calculatePaybackPeriod(input: FinancialInput): PaybackPeriodResu
if (cum >= 0) {
const prevCum = cum - netCashFlows[i];
if (netCashFlows[i] !== 0) {
simplePaybackYears = (i - 1) + Math.abs(prevCum) / netCashFlows[i];
simplePaybackYears = i - 1 + Math.abs(prevCum) / netCashFlows[i];
} else {
simplePaybackYears = i;
}
Expand All @@ -276,7 +278,7 @@ export function calculatePaybackPeriod(input: FinancialInput): PaybackPeriodResu
if (discCum >= 0) {
const prevDiscCum = discCum - discountedCashFlows[i];
if (discountedCashFlows[i] !== 0) {
discountedPaybackYears = (i - 1) + Math.abs(prevDiscCum) / discountedCashFlows[i];
discountedPaybackYears = i - 1 + Math.abs(prevDiscCum) / discountedCashFlows[i];
} else {
discountedPaybackYears = i;
}
Expand Down Expand Up @@ -319,60 +321,64 @@ export function calculateNPV(input: FinancialInput): NPVResult {
};
}

type SensitivityVariation =
| { label: string; kind: "multiplier"; value: number }
| { label: string; kind: "delta"; value: number };

type SensitivityParam = {
key: keyof FinancialInput;
label: string;
variations: { label: string; multiplier: number }[];
variations: SensitivityVariation[];
};

const SENSITIVITY_PARAMS: SensitivityParam[] = [
{
key: "installation_cost",
label: "Installation Cost",
variations: [
{ label: "-20%", multiplier: 0.80 },
{ label: "-10%", multiplier: 0.90 },
{ label: "+10%", multiplier: 1.10 },
{ label: "+20%", multiplier: 1.20 },
{ label: "-20%", kind: "multiplier", value: 0.8 },
{ label: "-10%", kind: "multiplier", value: 0.9 },
{ label: "+10%", kind: "multiplier", value: 1.1 },
{ label: "+20%", kind: "multiplier", value: 1.2 },
],
},
{
key: "electricity_price_per_kwh",
label: "Electricity Price",
variations: [
{ label: "-20%", multiplier: 0.80 },
{ label: "-10%", multiplier: 0.90 },
{ label: "+10%", multiplier: 1.10 },
{ label: "+20%", multiplier: 1.20 },
{ label: "-20%", kind: "multiplier", value: 0.8 },
{ label: "-10%", kind: "multiplier", value: 0.9 },
{ label: "+10%", kind: "multiplier", value: 1.1 },
{ label: "+20%", kind: "multiplier", value: 1.2 },
],
},
{
key: "discount_rate",
label: "Discount Rate",
variations: [
{ label: "-2%", multiplier: (1 / 0.07) * 0.05 },
{ label: "-1%", multiplier: (1 / 0.07) * 0.06 },
{ label: "+1%", multiplier: (1 / 0.07) * 0.08 },
{ label: "+2%", multiplier: (1 / 0.07) * 0.09 },
{ label: "-2%", kind: "delta", value: -0.02 },
{ label: "-1%", kind: "delta", value: -0.01 },
{ label: "+1%", kind: "delta", value: 0.01 },
{ label: "+2%", kind: "delta", value: 0.02 },
],
},
{
key: "degradation_rate",
label: "Degradation Rate",
variations: [
{ label: "-0.25%", multiplier: 0.5 },
{ label: "+0.25%", multiplier: 1.5 },
{ label: "+0.50%", multiplier: 2.0 },
{ label: "-0.25%", kind: "multiplier", value: 0.5 },
{ label: "+0.25%", kind: "multiplier", value: 1.5 },
{ label: "+0.50%", kind: "multiplier", value: 2.0 },
],
},
{
key: "annual_energy_output_kwh",
label: "Energy Output",
variations: [
{ label: "-20%", multiplier: 0.80 },
{ label: "-10%", multiplier: 0.90 },
{ label: "+10%", multiplier: 1.10 },
{ label: "+20%", multiplier: 1.20 },
{ label: "-20%", kind: "multiplier", value: 0.8 },
{ label: "-10%", kind: "multiplier", value: 0.9 },
{ label: "+10%", kind: "multiplier", value: 1.1 },
{ label: "+20%", kind: "multiplier", value: 1.2 },
],
},
];
Expand All @@ -392,10 +398,14 @@ export function performSensitivityAnalysis(input: FinancialInput): SensitivityRe
for (const param of SENSITIVITY_PARAMS) {
for (const variation of param.variations) {
let variedInput: FinancialInput;
if (param.key === "discount_rate") {
variedInput = { ...input, [param.key]: input[param.key] * variation.multiplier };
let effectiveMultiplier: number;
if (variation.kind === "delta") {
const variedValue = (input[param.key] as number) + variation.value;
variedInput = { ...input, [param.key]: variedValue };
effectiveMultiplier = variedValue / (input[param.key] as number);
} else {
variedInput = { ...input, [param.key]: (input[param.key] as number) * variation.multiplier };
variedInput = { ...input, [param.key]: (input[param.key] as number) * variation.value };
effectiveMultiplier = variation.value;
}
const npvResult = calculateNPV(variedInput);
const paybackResult = calculatePaybackPeriod(variedInput);
Expand All @@ -404,7 +414,7 @@ export function performSensitivityAnalysis(input: FinancialInput): SensitivityRe
label: `${param.label} ${variation.label}`,
parameter: param.label,
change: variation.label,
multiplier: variation.multiplier,
multiplier: effectiveMultiplier,
npv: npvResult.npv,
payback_years: paybackResult.payback_years,
irr: npvResult.irr,
Expand Down Expand Up @@ -434,7 +444,9 @@ function calculateProjectROI(projectId: number, input: FinancialInput): ProjectR
};
}

export function compareROI(projects: { project_id: number; input: FinancialInput }[]): ROIComparisonResult {
export function compareROI(
projects: { project_id: number; input: FinancialInput }[],
): ROIComparisonResult {
const all = projects.map((p) => calculateProjectROI(p.project_id, p.input));

const byROI = [...all].sort((a, b) => b.roi_pct - a.roi_pct);
Expand Down
Loading