Financial Analysis¶
Module: esfex.models.financial_analysis
Overview¶
Post-optimization financial assessment of energy system investments. Reads completed simulation results from HDF5 and computes investment metrics used in power project finance [68]: net present value (NPV) decomposition, internal rate of return (IRR), weighted average cost of capital (WACC), debt service coverage ratio (DSCR), levelized cost of energy (LCOE) [48] [64], and value-adjusted LCOE (VALCOE).
The module is GUI-independent and can be used from scripts and notebooks. The Financial Analysis Wizard provides a graphical interface to the same functions.
Data flow:
HDF5 results file (from Orchestrator.run())
│
├── /cost_breakdown/year_YYYY/ ──→ granular cost components (preferred)
│ (27 component attributes)
│
├── /detailed_results/year_YYYY/ ──→ generation, prices, investments
│ (gen_output, electricity_prices, ...)
│
└── /system_configuration/ ──→ fuel costs, invest costs, lifetimes
(generators/, batteries/)
↓
FinancialAssumptions + HDF5 data
↓
SystemFinancials / TechnologyFinancials / SensitivityResult / MonteCarloResult
When the /cost_breakdown/ group is present in HDF5 (see HDF5 schema), granular per-component costs are used directly. Otherwise, costs are recalculated from generation output and system configuration (fuel cost × generation, O&M × generation, etc.).
1. FinancialAssumptions¶
User-configurable financial parameters. All fields have sensible defaults and can be overridden selectively.
@dataclass
class FinancialAssumptions:
# Capital structure
debt_fraction: float = 0.60
cost_of_debt: float = 0.05
cost_of_equity: float = 0.12
debt_tenor: int = 15
# Tax & depreciation
tax_rate: float = 0.25
depreciation_method: str = "straight_line"
depreciation_years: int = 20
itc_rate: float = 0.0
ptc_rate: float = 0.0
# Revenue
ppa_price: float = 0.0
ppa_escalation: float = 0.02
capacity_payment: float = 0.0
rec_price: float = 0.0
# Environmental
carbon_price: float = 0.0
carbon_price_escalation: float = 0.02
# Other
insurance_rate: float = 0.005
salvage_fraction: float = 0.05
discount_rate: float = 0.08
Capital Structure¶
| Field | Default | Description |
|---|---|---|
debt_fraction |
0.60 | Proportion of capital financed through debt, \(D \in [0, 1]\). Typical utility-scale projects: 0.60–0.80. |
cost_of_debt |
0.05 | Annual interest rate on debt, \(r_d\). |
cost_of_equity |
0.12 | Required return on equity, \(r_e\). Reflects the risk premium investors require above the risk-free rate. |
debt_tenor |
15 | Loan repayment period in years, \(T\). |
Tax & Depreciation¶
| Field | Default | Description |
|---|---|---|
tax_rate |
0.25 | Corporate tax rate, \(t\). |
depreciation_method |
"straight_line" |
"straight_line": uniform annual deduction over depreciation_years. "macrs": Modified Accelerated Cost Recovery using a 5-year schedule (20%, 32%, 19.2%, 11.52%, 11.52%, 5.76%). |
depreciation_years |
20 | Asset depreciation lifetime for tax purposes. |
itc_rate |
0.0 | Investment Tax Credit as fraction of CAPEX. Applied as a lump-sum benefit in year 0. |
ptc_rate |
0.0 | Production Tax Credit in $/MWh. Applied annually proportional to renewable generation. |
Revenue¶
| Field | Default | Description |
|---|---|---|
ppa_price |
0.0 | Power Purchase Agreement price ($/MWh). When 0, nodal prices from the optimization (shadow prices of the power balance constraint) are used as the revenue basis. |
ppa_escalation |
0.02 | Annual PPA price escalation rate. Price in year \(y\): \(P_0 (1 + e)^y\). |
capacity_payment |
0.0 | Annual capacity payment ($/MW-year). Multiplied by total installed capacity. |
rec_price |
0.0 | Renewable Energy Certificate price ($/MWh). Applied to renewable generation only. |
Environmental¶
| Field | Default | Description |
|---|---|---|
carbon_price |
0.0 | Carbon price ($/tCO2). Revenue is computed from avoided emissions relative to the first year. |
carbon_price_escalation |
0.02 | Annual carbon price escalation rate. |
Other¶
| Field | Default | Description |
|---|---|---|
insurance_rate |
0.005 | Annual insurance cost as fraction of total CAPEX. |
salvage_fraction |
0.05 | Residual asset value at end of life, as fraction of CAPEX. Applied in the final year. |
discount_rate |
0.08 | Discount rate for NPV calculations, \(r\). |
2. compute_system_financials¶
def compute_system_financials(
h5_path: Path | str,
assumptions: FinancialAssumptions | None = None,
) -> SystemFinancials
Compute system-level financial analysis from simulation results.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
h5_path |
Path or str |
required | Path to the HDF5 results file. |
assumptions |
FinancialAssumptions or None |
None |
Financial parameters. Uses defaults if None. |
Returns: SystemFinancials containing NPV decomposition, annual cash flow table, return metrics, debt metrics, and system LCOE.
Computation pipeline:
- Load system configuration (generators, batteries, technologies) and year list from HDF5.
- For each year, load generation output, investment decisions, and electricity prices.
- Compute annual costs: if
/cost_breakdown/year_YYYY/exists, read directly; otherwise recalculate from generation × unit cost parameters. - Compute annual revenue from energy sales (PPA or nodal prices), carbon credits, RECs, and capacity payments.
- Compute WACC, depreciation schedule, debt service, and tax.
- Build the annual cash flow table and discount all flows to NPV.
- Compute IRR, MIRR, payback, DSCR, LLCR, and system LCOE.
Example:
from esfex.models.financial_analysis import (
FinancialAssumptions,
compute_system_financials,
)
assumptions = FinancialAssumptions(
discount_rate=0.08,
debt_fraction=0.70,
cost_of_debt=0.04,
tax_rate=0.20,
ppa_price=65.0,
carbon_price=25.0,
)
sf = compute_system_financials("results/results_isla.h5", assumptions)
print(f"NPV: ${sf.npv_total:,.0f}")
print(f"Project IRR: {sf.project_irr:.1%}")
print(f"WACC: {sf.wacc:.1%}")
print(f"DSCR (min): {sf.dscr_min:.2f}")
print(f"System LCOE: ${sf.lcoe_system:.1f}/MWh")
print(f"Payback: {sf.payback_discounted:.1f} years")
# Annual cash flow table (pandas DataFrame)
print(sf.cash_flows[["year", "revenue", "net_cash_flow", "cumulative_npv"]])
3. SystemFinancials¶
Complete system-level financial results.
NPV Decomposition¶
Each component is discounted using Eq. (FA-1):
| Field | Type | Description |
|---|---|---|
npv_capex |
float |
NPV of capital expenditures ($). |
npv_fuel |
float |
NPV of fuel costs ($). |
npv_om |
float |
NPV of O&M + startup + insurance costs ($). |
npv_decommissioning |
float |
NPV of decommissioning costs ($). |
npv_penalties |
float |
NPV of penalty costs: load shedding, curtailment, RE shortfall ($). |
npv_revenue |
float |
NPV of energy revenue ($). |
npv_tax_benefits |
float |
NPV of tax depreciation benefits + ITC + PTC ($). |
npv_salvage |
float |
NPV of residual asset value ($). |
npv_total |
float |
Net project NPV: revenue + tax benefits + salvage − all costs ($). |
Cash Flows¶
| Field | Type | Description |
|---|---|---|
cash_flows |
pd.DataFrame |
Annual pro-forma cash flow table. Columns: year, revenue, fuel_cost, om_cost, insurance, capex, depreciation, tax, ptc_benefit, debt_service, net_cash_flow, equity_cash_flow, cumulative_npv, dscr. |
Return Metrics¶
| Field | Type | Description |
|---|---|---|
project_irr |
float |
Project IRR. Discount rate at which NPV = 0, using pre-debt cash flows. |
equity_irr |
float |
Equity IRR. Uses post-debt-service cash flows. |
mirr |
float |
Modified IRR [69]. Finance rate = cost of debt; reinvestment rate = cost of equity. |
payback_simple |
float |
Simple payback period (years). |
payback_discounted |
float |
Discounted payback period (years). |
wacc |
float |
Weighted Average Cost of Capital. |
profitability_index |
float |
NPV of revenue / NPV of CAPEX. |
Debt Metrics¶
| Field | Type | Description |
|---|---|---|
dscr_annual |
np.ndarray |
Annual DSCR: \(CFADS_y / DS_y\). |
dscr_min |
float |
Minimum annual DSCR during the debt tenor. |
llcr |
float |
Loan Life Coverage Ratio: NPV of CFADS over loan tenor / outstanding debt. |
cfads |
np.ndarray |
Annual Cash Flow Available for Debt Service ($). |
System Cost Metrics¶
| Field | Type | Description |
|---|---|---|
lcoe_system |
float |
System LCOE ($/MWh): NPV(costs) / NPV(generation) [48] [64]. |
lcoe_by_tech |
dict |
Per-technology LCOE: {name: float}. |
lcos_by_battery |
dict |
Per-battery levelized cost of storage: {name: float}. |
valcoe_by_tech |
dict |
Per-technology VALCOE: {name: float}. |
tech_financials |
dict |
Per-technology detailed financials: {name: TechnologyFinancials}. |
4. compute_technology_financials¶
def compute_technology_financials(
h5_path: Path | str,
assumptions: FinancialAssumptions | None = None,
) -> dict[str, TechnologyFinancials]
Compute per-technology financial breakdown. Returns one TechnologyFinancials entry per generator and battery, including virtual units from technology investments.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
h5_path |
Path or str |
required | Path to the HDF5 results file. |
assumptions |
FinancialAssumptions or None |
None |
Financial parameters. |
Returns: dict mapping technology name to TechnologyFinancials.
Per-technology LCOE computation:
where \(I_g\) is total capital cost, \(L_g\) is asset lifetime, \(CRF\) is the capital recovery factor (Eq. FA-6 in Wizard formulations), and \(E_g^{\text{annual}}\) is average annual generation.
Example:
techs = compute_technology_financials("results/results_isla.h5")
for name, tf in techs.items():
print(f"{name}:")
print(f" Installed: {tf.installed_mw:.0f} MW")
print(f" CF: {tf.capacity_factor:.1%}")
print(f" LCOE: ${tf.lcoe:.1f}/MWh")
print(f" VALCOE: ${tf.valcoe:.1f}/MWh")
print(f" Revenue: ${tf.revenue_total:,.0f}")
print(f" ROI: {tf.roi:.1%}")
5. TechnologyFinancials¶
Per-technology financial breakdown.
Investment and Capacity¶
| Field | Type | Description |
|---|---|---|
name |
str |
Technology name (e.g., "Solar_PV", "Investment Battery"). |
tech_type |
str |
"generator" or "battery". |
fuel_type |
str |
Fuel type (e.g., "Sun", "Diesel", "Li-ion"). |
capex_total |
float |
Total capital expenditure ($). |
installed_mw |
float |
Installed capacity (MW). |
Production¶
| Field | Type | Description |
|---|---|---|
generation_mwh |
float |
Total generation over all years (MWh). |
annual_generation |
np.ndarray |
Annual generation (MWh), one element per year. |
capacity_factor |
float |
Average capacity factor: \(\overline{CF} = E_{\text{total}} / (P_{\text{rated}} \cdot H \cdot N)\). |
Revenue and Costs¶
| Field | Type | Description |
|---|---|---|
revenue_total |
float |
Total revenue ($). |
annual_revenue |
np.ndarray |
Annual revenue ($). |
average_selling_price |
float |
Revenue-weighted average selling price ($/MWh): \(\sum \lambda_t p_t / \sum p_t\). |
fuel_cost_total |
float |
Total fuel cost ($). |
om_cost_total |
float |
Total O&M cost ($). |
startup_cost_total |
float |
Total startup cost ($). |
co2_cost_total |
float |
Total CO2 emission cost ($). |
Performance Metrics¶
| Field | Type | Description |
|---|---|---|
lcoe |
float |
Levelized cost of energy ($/MWh). |
valcoe |
float |
Value-adjusted LCOE ($/MWh). Negative = profitable at market prices. |
roi |
float |
Return on investment: \((R - C) / CAPEX\). |
contribution_to_npv |
float |
Technology's contribution to system NPV ($). |
Storage-Specific (batteries only; NaN for generators)¶
| Field | Type | Description |
|---|---|---|
lcos |
float |
Levelized cost of storage ($/MWh discharged). |
arbitrage_revenue |
float |
Arbitrage revenue from price spreads ($). |
degradation_cost |
float |
Battery degradation cost ($). |
6. run_sensitivity_analysis¶
def run_sensitivity_analysis(
h5_path: Path | str,
assumptions: FinancialAssumptions,
variables: list[str] | None = None,
range_pct: float = 0.30,
n_points: int = 11,
) -> SensitivityResult
One-at-a-time (OAT) sensitivity analysis [12]. Sweeps each selected financial assumption independently over a range around its base value and records the impact on NPV and IRR. Produces data for tornado diagrams and spider plots.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
h5_path |
Path or str |
required | HDF5 results file. |
assumptions |
FinancialAssumptions |
required | Base-case assumptions. |
variables |
list[str] or None |
None |
Assumption field names to sweep. Defaults to ["discount_rate", "ppa_price", "carbon_price", "debt_fraction", "cost_of_debt", "tax_rate"]. |
range_pct |
float |
0.30 | Variation range: ±30% of base value. For base=0, sweeps from \(-\Delta\) to \(+\Delta\). |
n_points |
int |
11 | Evaluation points per variable (linearly spaced). |
Returns: SensitivityResult.
For each variable \(x_k\), the sweep evaluates NPV and IRR at \(n\) points linearly spaced from \(x_k(1-\Delta)\) to \(x_k(1+\Delta)\):
Example:
from esfex.models.financial_analysis import (
FinancialAssumptions,
run_sensitivity_analysis,
)
assumptions = FinancialAssumptions(discount_rate=0.08, ppa_price=65.0)
result = run_sensitivity_analysis(
"results.h5",
assumptions,
variables=["discount_rate", "ppa_price", "carbon_price", "tax_rate"],
range_pct=0.40,
)
# Tornado: rank by NPV spread
for var, (npv_lo, npv_hi) in sorted(
result.tornado.items(),
key=lambda x: abs(x[1][1] - x[1][0]),
reverse=True,
):
print(f" {var}: NPV range ${npv_lo:,.0f} to ${npv_hi:,.0f}")
# Break-even: discount rate where NPV = 0
if "discount_rate" in result.breakeven:
print(f" Break-even discount rate: {result.breakeven['discount_rate']:.2%}")
SensitivityResult¶
| Field | Type | Description |
|---|---|---|
base_npv |
float |
NPV at base-case assumptions ($). |
base_irr |
float |
IRR at base-case assumptions. |
sweeps |
dict |
{variable: [(value, npv, irr), ...]}. Full sweep data. |
tornado |
dict |
{variable: (npv_low, npv_high)}. NPV at the extremes of each sweep. |
breakeven |
dict |
{variable: value}. Parameter value where NPV crosses zero (linear interpolation). Only present when a sign change occurs within the sweep range. |
7. run_monte_carlo¶
def run_monte_carlo(
h5_path: Path | str,
assumptions: FinancialAssumptions,
distributions: dict[str, tuple[str, float, float]] | None = None,
n_samples: int = 1000,
seed: int | None = 42,
) -> MonteCarloResult
Monte Carlo simulation of financial outcomes [70]. Samples financial assumptions from probability distributions, computes NPV and IRR for each sample, and reports summary statistics including Value-at-Risk (VaR) and Conditional VaR (CVaR).
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
h5_path |
Path or str |
required | HDF5 results file. |
assumptions |
FinancialAssumptions |
required | Base-case assumptions. |
distributions |
dict or None |
None |
Variable distributions. Format: {field_name: (dist_type, param1, param2)}. See table below. Defaults to normal distributions on discount rate and PPA price, uniform on tax rate. |
n_samples |
int |
1000 | Monte Carlo iterations. |
seed |
int or None |
42 | Random seed. None for non-deterministic. |
Supported distributions:
| Type | Parameters | Sampling |
|---|---|---|
"normal" |
\(\mu\), \(\sigma\) | \(X \sim \mathcal{N}(\mu, \sigma^2)\) |
"uniform" |
\(a\), \(b\) | \(X \sim \mathcal{U}(a, b)\) |
"triangular" |
\(a\), \(b\) | \(X \sim \text{Tri}(a, \; x_0, \; b)\) where \(x_0\) is the base-case value (mode) |
Risk metrics:
where \(\alpha = 0.05\). VaR is the NPV value exceeded with 95% probability; CVaR is the expected NPV in the worst 5% of outcomes.
Example:
from esfex.models.financial_analysis import (
FinancialAssumptions,
run_monte_carlo,
)
assumptions = FinancialAssumptions(discount_rate=0.08, ppa_price=65.0)
mc = run_monte_carlo(
"results.h5",
assumptions,
distributions={
"discount_rate": ("normal", 0.08, 0.01),
"ppa_price": ("normal", 65.0, 8.0),
"carbon_price": ("uniform", 10.0, 50.0),
"tax_rate": ("triangular", 0.15, 0.35),
},
n_samples=5000,
)
print(f"NPV mean: ${mc.npv_mean:,.0f}")
print(f"NPV median: ${mc.npv_p50:,.0f}")
print(f"NPV std: ${mc.npv_std:,.0f}")
print(f"VaR (5%): ${mc.npv_var_5:,.0f}")
print(f"CVaR (5%): ${mc.npv_cvar_5:,.0f}")
print(f"IRR mean: {mc.irr_mean:.1%}")
print(f"P(NPV > 0): {(mc.npv_samples > 0).mean():.0%}")
MonteCarloResult¶
| Field | Type | Description |
|---|---|---|
n_samples |
int |
Number of iterations. |
npv_samples |
np.ndarray |
All NPV samples. Shape: (n_samples,). |
irr_samples |
np.ndarray |
All IRR samples. Shape: (n_samples,). |
npv_mean |
float |
Mean NPV ($). |
npv_std |
float |
Standard deviation of NPV ($). |
npv_p5 |
float |
5th percentile NPV. |
npv_p25 |
float |
25th percentile NPV. |
npv_p50 |
float |
Median NPV. |
npv_p75 |
float |
75th percentile NPV. |
npv_p95 |
float |
95th percentile NPV. |
npv_var_5 |
float |
Value-at-Risk at 5%: NPV exceeded with 95% probability. |
npv_cvar_5 |
float |
Conditional VaR at 5%: mean NPV of the worst 5% outcomes. |
irr_mean |
float |
Mean IRR. |
irr_std |
float |
Standard deviation of IRR. |
8. Full Example¶
from esfex.models.financial_analysis import (
FinancialAssumptions,
compute_system_financials,
compute_technology_financials,
run_sensitivity_analysis,
run_monte_carlo,
)
# 1. Configure assumptions for a specific market
assumptions = FinancialAssumptions(
discount_rate=0.08,
debt_fraction=0.70,
cost_of_debt=0.045,
cost_of_equity=0.14,
debt_tenor=18,
tax_rate=0.22,
depreciation_method="macrs",
ppa_price=62.0,
ppa_escalation=0.015,
carbon_price=30.0,
carbon_price_escalation=0.03,
itc_rate=0.30,
)
h5 = "results/results_isla_juventud.h5"
# 2. System-level analysis
sf = compute_system_financials(h5, assumptions)
print(f"NPV: ${sf.npv_total:,.0f}")
print(f"Project IRR: {sf.project_irr:.1%}")
print(f"Equity IRR: {sf.equity_irr:.1%}")
print(f"WACC: {sf.wacc:.1%}")
print(f"System LCOE: ${sf.lcoe_system:.1f}/MWh")
print(f"Min DSCR: {sf.dscr_min:.2f}")
print(f"Payback: {sf.payback_discounted:.1f} years")
# Cash flow table
print(sf.cash_flows[["year", "revenue", "fuel_cost", "net_cash_flow", "dscr"]])
# 3. Per-technology breakdown
techs = compute_technology_financials(h5, assumptions)
for name, tf in techs.items():
print(f" {name}: LCOE=${tf.lcoe:.1f}/MWh, CF={tf.capacity_factor:.1%}, "
f"ROI={tf.roi:.0%}")
# 4. Sensitivity — which assumptions matter most?
sens = run_sensitivity_analysis(h5, assumptions)
for var, (lo, hi) in sorted(
sens.tornado.items(),
key=lambda x: abs(x[1][1] - x[1][0]),
reverse=True,
):
print(f" {var}: NPV range ${lo:,.0f} to ${hi:,.0f}")
# 5. Monte Carlo — what is the probability of success?
mc = run_monte_carlo(h5, assumptions, n_samples=5000)
print(f"NPV P50: ${mc.npv_p50:,.0f}")
print(f"VaR (5%): ${mc.npv_var_5:,.0f}")
print(f"P(NPV > 0): {(mc.npv_samples > 0).mean():.0%}")