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
137 changes: 101 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,51 +1,116 @@
# DCAF

The Discounted Cash Flow Analysis Framework is a professional financial engineering
library for nuclear energy project cost and metric calculations, with a focus on
Extended Power Uprate (EPU) projects.
DCAF (Discounted Cash Flow Analysis Framework) is a Python library for the financial
analysis of capital-intensive energy projects. Describe a project in a few lines:
capacity, construction cost, financing, revenue, operating costs, and tax incentives.
DCAF gives you back NPV, IRR, levelized cost, and a full year-by-year pro forma.

## Installation
DCAF was first built at Idaho National Laboratory to evaluate nuclear power uprates,
and it still handles those well. The same tools apply to any project where a large
upfront build is paid back by decades of generation.

Install the `dcaf-idaholab` distribution from PyPI:
## Installation

```bash
uv add dcaf-idaholab
# or: pip install dcaf-idaholab
pip install dcaf-idaholab
# or: uv add dcaf-idaholab
```

The Python import package remains `dcaf`:
DCAF supports Python 3.11 and newer. The package installs as `dcaf-idaholab` and
imports as `dcaf`:

```python
from dcaf import EnergyProject
```

## Getting Started

This example models a 220 MW plant. It costs $600M to build over five years, half of
it financed with debt. Once running, it sells power at $45/MWh for 30 years and
collects a Production Tax Credit for the first 10.

```python
from datetime import date
from dcaf import EnergyProject

project = (
EnergyProject()
.generation(
capacity_mw=220,
capacity_factor=0.92,
operations_start=date(2032, 1, 1),
operations_end=date(2062, 1, 1), # exclusive end: 30 operating years
)
.construction(
overnight_cost=600e6,
construction_start=date(2027, 1, 1),
spend_profile="bell", # S-curve spending over the 5-year build
)
.construction_financing(debt_fraction=0.5, amortization_rate=0.06, amortization_term=20)
.generation_revenue(price=45.00) # $/MWh
.fixed_opex(amount=8e6) # $/year
.production_tax_credit(rate_per_unit=27.50) # $/MWh for the first 10 years
.tax(rate=0.21)
.depreciation_macrs(property_class=15)
)

analysis = project.analyze()
metrics = analysis.metrics(discount_rate=0.08, valuation_date=date(2027, 1, 1))

print(f"NPV: ${metrics.npv / 1e6:,.1f} M")
print(f"IRR: {metrics.xirr:.1%}")
print(f"LCOE: ${metrics.levelized_cost:,.2f}/MWh")

analysis.pro_forma(period="year").to_csv("pro_forma.csv")
```

```text
NPV: $299.2 M
IRR: 17.4%
LCOE: $17.12/MWh
```

`pro_forma.csv` holds the full annual statement: revenue, operating costs, EBITDA,
depreciation, taxes, tax credits, debt service, and free cash flow to the firm and to
equity. Every number in it traces back to a named cash flow in `analysis`, so you can
check any result line by line.

## Overview

DCAF provides high-fidelity cost modeling and financial analysis tools
specifically designed for nuclear energy projects. The library enables detailed
financial analysis including:

- Net Present Value (NPV), Discounted Cashflow (DCF) and Internal Rate of Return
(IRR) calculations
- Financing cost modeling with accurate interest calculations
- Tax and incentive analysis
- Production Tax Credits
- Investment Tax Credits
- MACRS and VDB depreciation scheduling
- Pro-forma generation for detailed financial projections
- Comprehensive cost modeling for EPU projects

## Day Count Conventions

Project analyses default to `actual/actual`, which uses real calendar days and
splits annual-rate calculations by calendar year so leap-year days use a 366-day
denominator. Use `actual/365-no-leap` when you intentionally want normalized
365-day project years that exclude Feb. 29 from elapsed-day counts. Use
`actual/365-fixed` when you need actual calendar days divided by 365.

The project-wide default is set with `EnergyProject(day_count_convention=...)`
or `.day_count_convention(...)`, and it flows into NPV, IRR, LCOE, escalation,
construction interest, operating-year proration, and capacity-hour generation.

Bank-loan and bond-style conventions such as `actual/360` and `30/360` are
useful future additions, but they are not implemented yet.
**Build a project in one expression.** `EnergyProject` is a fluent builder. Each
method adds one piece of the project and returns a new, immutable copy, so you can
set up a base case once and branch scenarios from it.

**Get standard metrics and pro formas.** NPV, IRR, and levelized cost (LCOE),
discounted to any valuation date. Pro formas aggregate by year, quarter, month, or
day and export to CSV.

**Model construction and financing in detail.**
- Spend curves (bell, flat, ramped, triangle, linear, upfront) with cost escalation
- Construction loans that accrue interest from each draw date, with capitalized or
paid interest
- Amortizing debt, custom debt schedules, and WACC-based discounting

**Price revenue the way contracts are written.**
- Fixed prices, dated price schedules, or your own pricing function
- Power purchase agreements that buy a share of generation or a fixed volume, with
the remainder sold at a merchant price
- Planned outages during construction or operations, with their lost revenue

**Handle tax and incentives.**
- Income tax with deductible costs and an interest tax shield
- MACRS (half-year and mid-quarter) and VDB depreciation
- Production Tax Credits (with escalation) and Investment Tax Credits

**Drop down a level when you need to.** The builder is made of public primitives:
`CashFlowStream`, `GenerationStream`, and the functions in `dcaf.finance`,
`dcaf.tax`, and `dcaf.metrics`. Use them on their own, or inject your own cash flows
into a project with `add_cashflow_stream()`.

## Learn More

- [Examples](https://github.com/IdahoLabResearch/DCAF/tree/main/examples): runnable
scripts that model the same project at five levels of the API, plus a multi-PPA
revenue example
- [Documentation](https://github.com/IdahoLabResearch/DCAF/tree/main/docs): concepts,
task guides, and the math behind every calculation
26 changes: 22 additions & 4 deletions tests/test_documented_outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
# ALL RIGHTS RESERVED
"""Documented example outputs must match what the examples print.

Each example listed in ``examples/README.md`` and the code block in
``docs/getting_started.md`` is run as a subprocess. Its printed output is compared with the
values documented next to it. The documented values are parsed from the docs themselves, so
updating a doc and the code that produces it is a single change.
Each example listed in ``examples/README.md``, the code block in ``docs/getting_started.md``,
and the Getting Started example in the root ``README.md`` is run as a subprocess. Its printed
output is compared with the values documented next to it. The documented values are parsed
from the docs themselves, so updating a doc and the code that produces it is a single change.

A script that exits nonzero, prints no value, or has no documented row fails its test.
"""
Expand All @@ -23,6 +23,7 @@
ROOT = Path(__file__).resolve().parent.parent
README = ROOT / "examples" / "README.md"
GETTING_STARTED = ROOT / "docs" / "getting_started.md"
ROOT_README = ROOT / "README.md"

# README table key -> example script. The key is the backticked name in the table's first column.
README_EXAMPLES = {
Expand All @@ -44,6 +45,8 @@
# "NPV: $318,988,351" and "LCOE: $23.30/MWh" in both the documented and printed output
_NPV = re.compile(r"^NPV:\s*\$\s*(-?[\d,]+(?:\.\d+)?)", re.MULTILINE)
_LCOE = re.compile(r"^LCOE:\s*\$\s*(-?[\d,]+(?:\.\d+)?)", re.MULTILINE)
# A python code block followed directly by its expected-output text block
_CODE_AND_OUTPUT = re.compile(r"```python\n((?:(?!```).)*)```\s*```text\n(.*?)```", re.DOTALL)


def _run(script: Path, cwd: Path) -> str:
Expand Down Expand Up @@ -127,3 +130,18 @@ def test_getting_started_matches_code_block(tmp_path: Path) -> None:
assert not mismatches, "docs/getting_started.md differs from its code block: " + "; ".join(
mismatches
)


def test_root_readme_example_matches_output(tmp_path: Path) -> None:
blocks = _CODE_AND_OUTPUT.findall(ROOT_README.read_text())
assert len(blocks) == 1, f"expected one example with output in README.md, found {len(blocks)}"
code, expected = blocks[0]

script = tmp_path / "readme_example.py"
script.write_text(code)
printed = _run(script, cwd=tmp_path)

# The README rounds every value, so its output block is compared verbatim.
assert printed.strip() == expected.strip(), (
f"README.md documents:\n{expected.strip()}\nthe example prints:\n{printed.strip()}"
)
Loading