diff --git a/README.md b/README.md index 6647a4d..33f5e0a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/tests/test_documented_outputs.py b/tests/test_documented_outputs.py index 82bcc41..a0dfba9 100644 --- a/tests/test_documented_outputs.py +++ b/tests/test_documented_outputs.py @@ -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. """ @@ -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 = { @@ -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: @@ -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()}" + )