Spaces:
Sleeping
Sleeping
Durand D'souza
commited on
Commit
·
c9568ab
unverified
·
0
Parent(s):
Initial commit of LCOE calculator
Browse files- .gitignore +191 -0
- .python-version +1 -0
- README.md +0 -0
- main.py +10 -0
- model.py +84 -0
- notebook.ipynb +211 -0
- pyproject.toml +23 -0
- schema.py +115 -0
- uv.lock +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by https://www.toptal.com/developers/gitignore/api/python,linux
|
| 2 |
+
# Edit at https://www.toptal.com/developers/gitignore?templates=python,linux
|
| 3 |
+
|
| 4 |
+
### Linux ###
|
| 5 |
+
*~
|
| 6 |
+
|
| 7 |
+
# temporary files which can be created if a process still has a handle open of a deleted file
|
| 8 |
+
.fuse_hidden*
|
| 9 |
+
|
| 10 |
+
# KDE directory preferences
|
| 11 |
+
.directory
|
| 12 |
+
|
| 13 |
+
# Linux trash folder which might appear on any partition or disk
|
| 14 |
+
.Trash-*
|
| 15 |
+
|
| 16 |
+
# .nfs files are created when an open file is removed but is still being accessed
|
| 17 |
+
.nfs*
|
| 18 |
+
|
| 19 |
+
### Python ###
|
| 20 |
+
# Byte-compiled / optimized / DLL files
|
| 21 |
+
__pycache__/
|
| 22 |
+
*.py[cod]
|
| 23 |
+
*$py.class
|
| 24 |
+
|
| 25 |
+
# C extensions
|
| 26 |
+
*.so
|
| 27 |
+
|
| 28 |
+
# Distribution / packaging
|
| 29 |
+
.Python
|
| 30 |
+
build/
|
| 31 |
+
develop-eggs/
|
| 32 |
+
dist/
|
| 33 |
+
downloads/
|
| 34 |
+
eggs/
|
| 35 |
+
.eggs/
|
| 36 |
+
lib/
|
| 37 |
+
lib64/
|
| 38 |
+
parts/
|
| 39 |
+
sdist/
|
| 40 |
+
var/
|
| 41 |
+
wheels/
|
| 42 |
+
share/python-wheels/
|
| 43 |
+
*.egg-info/
|
| 44 |
+
.installed.cfg
|
| 45 |
+
*.egg
|
| 46 |
+
MANIFEST
|
| 47 |
+
|
| 48 |
+
# PyInstaller
|
| 49 |
+
# Usually these files are written by a python script from a template
|
| 50 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 51 |
+
*.manifest
|
| 52 |
+
*.spec
|
| 53 |
+
|
| 54 |
+
# Installer logs
|
| 55 |
+
pip-log.txt
|
| 56 |
+
pip-delete-this-directory.txt
|
| 57 |
+
|
| 58 |
+
# Unit test / coverage reports
|
| 59 |
+
htmlcov/
|
| 60 |
+
.tox/
|
| 61 |
+
.nox/
|
| 62 |
+
.coverage
|
| 63 |
+
.coverage.*
|
| 64 |
+
.cache
|
| 65 |
+
nosetests.xml
|
| 66 |
+
coverage.xml
|
| 67 |
+
*.cover
|
| 68 |
+
*.py,cover
|
| 69 |
+
.hypothesis/
|
| 70 |
+
.pytest_cache/
|
| 71 |
+
cover/
|
| 72 |
+
|
| 73 |
+
# Translations
|
| 74 |
+
*.mo
|
| 75 |
+
*.pot
|
| 76 |
+
|
| 77 |
+
# Django stuff:
|
| 78 |
+
*.log
|
| 79 |
+
local_settings.py
|
| 80 |
+
db.sqlite3
|
| 81 |
+
db.sqlite3-journal
|
| 82 |
+
|
| 83 |
+
# Flask stuff:
|
| 84 |
+
instance/
|
| 85 |
+
.webassets-cache
|
| 86 |
+
|
| 87 |
+
# Scrapy stuff:
|
| 88 |
+
.scrapy
|
| 89 |
+
|
| 90 |
+
# Sphinx documentation
|
| 91 |
+
docs/_build/
|
| 92 |
+
|
| 93 |
+
# PyBuilder
|
| 94 |
+
.pybuilder/
|
| 95 |
+
target/
|
| 96 |
+
|
| 97 |
+
# Jupyter Notebook
|
| 98 |
+
.ipynb_checkpoints
|
| 99 |
+
|
| 100 |
+
# IPython
|
| 101 |
+
profile_default/
|
| 102 |
+
ipython_config.py
|
| 103 |
+
|
| 104 |
+
# pyenv
|
| 105 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 106 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 107 |
+
# .python-version
|
| 108 |
+
|
| 109 |
+
# pipenv
|
| 110 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 111 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 112 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 113 |
+
# install all needed dependencies.
|
| 114 |
+
#Pipfile.lock
|
| 115 |
+
|
| 116 |
+
# poetry
|
| 117 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 118 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 119 |
+
# commonly ignored for libraries.
|
| 120 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 121 |
+
#poetry.lock
|
| 122 |
+
|
| 123 |
+
# pdm
|
| 124 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 125 |
+
#pdm.lock
|
| 126 |
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
| 127 |
+
# in version control.
|
| 128 |
+
# https://pdm.fming.dev/#use-with-ide
|
| 129 |
+
.pdm.toml
|
| 130 |
+
|
| 131 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 132 |
+
__pypackages__/
|
| 133 |
+
|
| 134 |
+
# Celery stuff
|
| 135 |
+
celerybeat-schedule
|
| 136 |
+
celerybeat.pid
|
| 137 |
+
|
| 138 |
+
# SageMath parsed files
|
| 139 |
+
*.sage.py
|
| 140 |
+
|
| 141 |
+
# Environments
|
| 142 |
+
.env
|
| 143 |
+
.venv
|
| 144 |
+
env/
|
| 145 |
+
venv/
|
| 146 |
+
ENV/
|
| 147 |
+
env.bak/
|
| 148 |
+
venv.bak/
|
| 149 |
+
|
| 150 |
+
# Spyder project settings
|
| 151 |
+
.spyderproject
|
| 152 |
+
.spyproject
|
| 153 |
+
|
| 154 |
+
# Rope project settings
|
| 155 |
+
.ropeproject
|
| 156 |
+
|
| 157 |
+
# mkdocs documentation
|
| 158 |
+
/site
|
| 159 |
+
|
| 160 |
+
# mypy
|
| 161 |
+
.mypy_cache/
|
| 162 |
+
.dmypy.json
|
| 163 |
+
dmypy.json
|
| 164 |
+
|
| 165 |
+
# Pyre type checker
|
| 166 |
+
.pyre/
|
| 167 |
+
|
| 168 |
+
# pytype static type analyzer
|
| 169 |
+
.pytype/
|
| 170 |
+
|
| 171 |
+
# Cython debug symbols
|
| 172 |
+
cython_debug/
|
| 173 |
+
|
| 174 |
+
# PyCharm
|
| 175 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 176 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 177 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 178 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 179 |
+
#.idea/
|
| 180 |
+
|
| 181 |
+
### Python Patch ###
|
| 182 |
+
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
| 183 |
+
poetry.toml
|
| 184 |
+
|
| 185 |
+
# ruff
|
| 186 |
+
.ruff_cache/
|
| 187 |
+
|
| 188 |
+
# LSP config files
|
| 189 |
+
pyrightconfig.json
|
| 190 |
+
|
| 191 |
+
# End of https://www.toptal.com/developers/gitignore/api/python,linux
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.11
|
README.md
ADDED
|
File without changes
|
main.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Annotated
|
| 2 |
+
from fastapi import FastAPI, Query
|
| 3 |
+
from schema import SolarPVAssumptions
|
| 4 |
+
from model import calculate_cashflow_for_renewable_project, calculate_lcoe
|
| 5 |
+
|
| 6 |
+
app = FastAPI()
|
| 7 |
+
|
| 8 |
+
@app.get("/solarpv/")
|
| 9 |
+
def get_lcoe(pv_assumptions: Annotated[SolarPVAssumptions, Query()]):
|
| 10 |
+
return calculate_lcoe(pv_assumptions)
|
model.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import numpy as np
|
| 3 |
+
import polars as pl
|
| 4 |
+
from pyxirr import irr, npv
|
| 5 |
+
from functools import partial
|
| 6 |
+
from scipy.optimize import fsolve
|
| 7 |
+
|
| 8 |
+
from schema import SolarPVAssumptions
|
| 9 |
+
|
| 10 |
+
def calculate_cashflow_for_renewable_project(assumptions, tariff, return_model=False):
|
| 11 |
+
# Create a dataframe, starting with the period
|
| 12 |
+
model = pl.DataFrame(
|
| 13 |
+
{
|
| 14 |
+
"Period": [i for i in range(assumptions.project_lifetime_years + 1)],
|
| 15 |
+
}
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
model = model.with_columns(
|
| 19 |
+
Capacity_MW = pl.when(pl.col("Period") > 0).then(assumptions.capacity_mw).otherwise(0),
|
| 20 |
+
Capacity_Factor = pl.when(pl.col("Period") > 0).then(assumptions.capacity_factor).otherwise(0),
|
| 21 |
+
Tariff_per_MWh = pl.when(pl.col("Period") > 0).then(tariff).otherwise(0),
|
| 22 |
+
).with_columns(
|
| 23 |
+
Total_Generation_MWh = pl.col("Capacity_MW") * pl.col("Capacity_Factor") * 8760,
|
| 24 |
+
).with_columns(
|
| 25 |
+
Total_Revenues_mn = pl.col("Total_Generation_MWh") * pl.col("Tariff_per_MWh")/1000,
|
| 26 |
+
O_M_Costs_mn = pl.when(pl.col("Period") > 0).then(assumptions.capital_cost/1000 * assumptions.o_m_cost_pct_of_capital_cost).otherwise(0),
|
| 27 |
+
).with_columns(
|
| 28 |
+
Total_Operating_Costs_mn = pl.col("O_M_Costs_mn"),
|
| 29 |
+
).with_columns(
|
| 30 |
+
EBITDA_mn = pl.col("Total_Revenues_mn") - pl.col("Total_Operating_Costs_mn"),
|
| 31 |
+
).with_columns(
|
| 32 |
+
CFADS_mn = pl.col("EBITDA_mn"),
|
| 33 |
+
).with_columns(
|
| 34 |
+
Target_Debt_Service_mn = pl.when(pl.col("Period") == 0).then(0).otherwise(pl.col("CFADS_mn")/assumptions.dcsr),
|
| 35 |
+
Debt_Outstanding_EoP_mn = pl.when(pl.col("Period") == 0).then(assumptions.debt_pct_of_capital_cost * assumptions.capital_cost/1000).otherwise(0),
|
| 36 |
+
).with_columns(
|
| 37 |
+
Interest_Expense_mn = pl.when(pl.col("Period") == 0).then(0).otherwise(pl.col("Debt_Outstanding_EoP_mn").shift(1) * assumptions.cost_of_debt),
|
| 38 |
+
).with_columns(
|
| 39 |
+
Amortization_mn = pl.when(pl.col("Period") == 0).then(0).otherwise(pl.min_horizontal(pl.col("Target_Debt_Service_mn") - pl.col("Interest_Expense_mn"), pl.col("Debt_Outstanding_EoP_mn").shift(1))),
|
| 40 |
+
).with_columns(
|
| 41 |
+
Debt_Outstanding_EoP_mn = pl.when(pl.col("Period") == 0).then(pl.col(
|
| 42 |
+
"Debt_Outstanding_EoP_mn"
|
| 43 |
+
)).otherwise(pl.col("Debt_Outstanding_EoP_mn").shift(1) - pl.col("Amortization_mn")
|
| 44 |
+
)).with_columns(
|
| 45 |
+
Debt_Outstanding_BoP_mn = pl.col("Debt_Outstanding_EoP_mn").shift(1),
|
| 46 |
+
).to_pandas()
|
| 47 |
+
|
| 48 |
+
for period in model["Period"]:
|
| 49 |
+
if period > 1:
|
| 50 |
+
model.loc[period, "Interest_Expense_mn"] = model.loc[period, "Debt_Outstanding_BoP_mn"] * assumptions.cost_of_debt
|
| 51 |
+
model.loc[period, "Amortization_mn"] = min(
|
| 52 |
+
model.loc[period, "Target_Debt_Service_mn"] - model.loc[period, "Interest_Expense_mn"],
|
| 53 |
+
model.loc[period, "Debt_Outstanding_BoP_mn"]
|
| 54 |
+
)
|
| 55 |
+
model.loc[period, "Debt_Outstanding_EoP_mn"] = model.loc[period, "Debt_Outstanding_BoP_mn"] - model.loc[period, "Amortization_mn"]
|
| 56 |
+
if period < assumptions.project_lifetime_years:
|
| 57 |
+
model.loc[period + 1, "Debt_Outstanding_BoP_mn"] = model.loc[period, "Debt_Outstanding_EoP_mn"]
|
| 58 |
+
|
| 59 |
+
model = pl.DataFrame(model).with_columns(
|
| 60 |
+
# Straight line depreciation
|
| 61 |
+
Depreciation_mn = pl.when(pl.col("Period") > 0).then(assumptions.capital_cost/1000/assumptions.project_lifetime_years).otherwise(0),
|
| 62 |
+
).with_columns(
|
| 63 |
+
Taxable_Income_mn = pl.col("EBITDA_mn") - pl.col("Depreciation_mn") - pl.col("Interest_Expense_mn"),
|
| 64 |
+
).with_columns(
|
| 65 |
+
Tax_Liability_mn = pl.max_horizontal(0, assumptions.tax_rate * pl.col("Taxable_Income_mn"))
|
| 66 |
+
).with_columns(
|
| 67 |
+
Post_Tax_Net_Equity_Cashflow_mn = pl.when(pl.col("Period") == 0).then(-assumptions.capital_cost/1000 * assumptions.equity_pct_of_capital_cost).otherwise(pl.col("EBITDA_mn") - pl.col("Target_Debt_Service_mn") - pl.col("Tax_Liability_mn"))
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Calculate Post-Tax Equity IRR
|
| 71 |
+
post_tax_equity_irr = irr(model["Post_Tax_Net_Equity_Cashflow_mn"].to_numpy())
|
| 72 |
+
if return_model:
|
| 73 |
+
return model, post_tax_equity_irr, tariff
|
| 74 |
+
return post_tax_equity_irr - assumptions.cost_of_equity
|
| 75 |
+
|
| 76 |
+
def calculate_lcoe(assumptions: SolarPVAssumptions):
|
| 77 |
+
"""The LCOE is the breakeven tariff that makes the project NPV zero"""
|
| 78 |
+
# Define the objective function
|
| 79 |
+
objective_function = partial(calculate_cashflow_for_renewable_project, assumptions)
|
| 80 |
+
|
| 81 |
+
# Solve for the LCOE
|
| 82 |
+
LCOE_guess = 30
|
| 83 |
+
lcoe = fsolve(objective_function, LCOE_guess)[0] + 0.0001
|
| 84 |
+
return lcoe
|
notebook.ipynb
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "code",
|
| 5 |
+
"execution_count": 1,
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"outputs": [],
|
| 8 |
+
"source": [
|
| 9 |
+
"%load_ext autoreload\n",
|
| 10 |
+
"%autoreload 2"
|
| 11 |
+
]
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"cell_type": "code",
|
| 15 |
+
"execution_count": 69,
|
| 16 |
+
"metadata": {},
|
| 17 |
+
"outputs": [],
|
| 18 |
+
"source": [
|
| 19 |
+
"import numpy as np\n",
|
| 20 |
+
"import polars as pl\n",
|
| 21 |
+
"from pyxirr import irr, npv\n",
|
| 22 |
+
"from functools import partial\n",
|
| 23 |
+
"from scipy.optimize import fsolve"
|
| 24 |
+
]
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"cell_type": "code",
|
| 28 |
+
"execution_count": 44,
|
| 29 |
+
"metadata": {},
|
| 30 |
+
"outputs": [
|
| 31 |
+
{
|
| 32 |
+
"name": "stdout",
|
| 33 |
+
"output_type": "stream",
|
| 34 |
+
"text": [
|
| 35 |
+
"capacity_mw=30.0 capacity_factor=0.097 capital_expenditure_per_mw=670000.0 o_m_cost_pct_of_capital_cost=0.02 debt_pct_of_capital_cost=0.871 equity_pct_of_capital_cost=0.129 cost_of_debt=0.04 cost_of_equity=0.12 tax_rate=0.25 project_lifetime_years=20 dcsr=1.3 capital_cost=20100000.0 tax_adjusted_WACC=0.04161 wacc=0.050320000000000004\n"
|
| 36 |
+
]
|
| 37 |
+
}
|
| 38 |
+
],
|
| 39 |
+
"source": [
|
| 40 |
+
"\n",
|
| 41 |
+
"from schema import SolarPVAssumptions\n",
|
| 42 |
+
"# Example usage\n",
|
| 43 |
+
"assumptions = SolarPVAssumptions(\n",
|
| 44 |
+
" capacity_mw=30,\n",
|
| 45 |
+
" capital_expenditure_per_mw=670_000,\n",
|
| 46 |
+
" o_m_cost_pct_of_capital_cost=0.02,\n",
|
| 47 |
+
" capacity_factor=0.097,\n",
|
| 48 |
+
" project_lifetime_years=20,\n",
|
| 49 |
+
" debt_pct_of_capital_cost=0.871,\n",
|
| 50 |
+
" equity_pct_of_capital_cost=0.129,\n",
|
| 51 |
+
" cost_of_debt=0.04,\n",
|
| 52 |
+
" cost_of_equity=0.12,\n",
|
| 53 |
+
" tax_rate=0.25,\n",
|
| 54 |
+
" dcsr=1.3\n",
|
| 55 |
+
")\n",
|
| 56 |
+
"\n",
|
| 57 |
+
"print(assumptions)"
|
| 58 |
+
]
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"cell_type": "code",
|
| 62 |
+
"execution_count": null,
|
| 63 |
+
"metadata": {},
|
| 64 |
+
"outputs": [],
|
| 65 |
+
"source": [
|
| 66 |
+
"## Pro Forma\n",
|
| 67 |
+
"\n",
|
| 68 |
+
"# Starting LCOE\n",
|
| 69 |
+
"LCOE_guess = 81.44\n",
|
| 70 |
+
"\n",
|
| 71 |
+
"def calculate_cashflow(assumptions, LCOE_guess, return_model=False):\n",
|
| 72 |
+
" # Create a dataframe, starting with the period\n",
|
| 73 |
+
" model = pl.DataFrame(\n",
|
| 74 |
+
" {\n",
|
| 75 |
+
" \"Period\": [i for i in range(assumptions.project_lifetime_years + 1)],\n",
|
| 76 |
+
" }\n",
|
| 77 |
+
" )\n",
|
| 78 |
+
"\n",
|
| 79 |
+
" model = model.with_columns(\n",
|
| 80 |
+
" Capacity_MW = pl.when(pl.col(\"Period\") > 0).then(assumptions.capacity_mw).otherwise(0),\n",
|
| 81 |
+
" Capacity_Factor = pl.when(pl.col(\"Period\") > 0).then(assumptions.capacity_factor).otherwise(0),\n",
|
| 82 |
+
" LCOE = pl.when(pl.col(\"Period\") > 0).then(LCOE_guess).otherwise(0),\n",
|
| 83 |
+
" ).with_columns(\n",
|
| 84 |
+
" Total_Generation_MWh = pl.col(\"Capacity_MW\") * pl.col(\"Capacity_Factor\") * 8760,\n",
|
| 85 |
+
" ).with_columns(\n",
|
| 86 |
+
" Total_Revenues_mn = pl.col(\"Total_Generation_MWh\") * pl.col(\"LCOE\")/1000,\n",
|
| 87 |
+
" O_M_Costs_mn = pl.when(pl.col(\"Period\") > 0).then(assumptions.capital_cost/1000 * assumptions.o_m_cost_pct_of_capital_cost).otherwise(0),\n",
|
| 88 |
+
" ).with_columns(\n",
|
| 89 |
+
" Total_Operating_Costs_mn = pl.col(\"O_M_Costs_mn\"),\n",
|
| 90 |
+
" ).with_columns(\n",
|
| 91 |
+
" EBITDA_mn = pl.col(\"Total_Revenues_mn\") - pl.col(\"Total_Operating_Costs_mn\"),\n",
|
| 92 |
+
" ).with_columns(\n",
|
| 93 |
+
" CFADS_mn = pl.col(\"EBITDA_mn\"),\n",
|
| 94 |
+
" ).with_columns(\n",
|
| 95 |
+
" Target_Debt_Service_mn = pl.when(pl.col(\"Period\") == 0).then(0).otherwise(pl.col(\"CFADS_mn\")/assumptions.dcsr),\n",
|
| 96 |
+
" Debt_Outstanding_EoP_mn = pl.when(pl.col(\"Period\") == 0).then(assumptions.debt_pct_of_capital_cost * assumptions.capital_cost/1000).otherwise(0),\n",
|
| 97 |
+
" ).with_columns(\n",
|
| 98 |
+
" Interest_Expense_mn = pl.when(pl.col(\"Period\") == 0).then(0).otherwise(pl.col(\"Debt_Outstanding_EoP_mn\").shift(1) * assumptions.cost_of_debt),\n",
|
| 99 |
+
" ).with_columns(\n",
|
| 100 |
+
" Amortization_mn = pl.when(pl.col(\"Period\") == 0).then(0).otherwise(pl.min_horizontal(pl.col(\"Target_Debt_Service_mn\") - pl.col(\"Interest_Expense_mn\"), pl.col(\"Debt_Outstanding_EoP_mn\").shift(1))),\n",
|
| 101 |
+
" ).with_columns(\n",
|
| 102 |
+
" Debt_Outstanding_EoP_mn = pl.when(pl.col(\"Period\") == 0).then(pl.col(\n",
|
| 103 |
+
" \"Debt_Outstanding_EoP_mn\"\n",
|
| 104 |
+
" )).otherwise(pl.col(\"Debt_Outstanding_EoP_mn\").shift(1) - pl.col(\"Amortization_mn\")\n",
|
| 105 |
+
" )).with_columns(\n",
|
| 106 |
+
" Debt_Outstanding_BoP_mn = pl.col(\"Debt_Outstanding_EoP_mn\").shift(1),\n",
|
| 107 |
+
" ).to_pandas()\n",
|
| 108 |
+
"\n",
|
| 109 |
+
" for period in model[\"Period\"]:\n",
|
| 110 |
+
" if period > 1:\n",
|
| 111 |
+
" model.loc[period, \"Interest_Expense_mn\"] = model.loc[period, \"Debt_Outstanding_BoP_mn\"] * assumptions.cost_of_debt\n",
|
| 112 |
+
" model.loc[period, \"Amortization_mn\"] = min(\n",
|
| 113 |
+
" model.loc[period, \"Target_Debt_Service_mn\"] - model.loc[period, \"Interest_Expense_mn\"],\n",
|
| 114 |
+
" model.loc[period, \"Debt_Outstanding_BoP_mn\"]\n",
|
| 115 |
+
" )\n",
|
| 116 |
+
" model.loc[period, \"Debt_Outstanding_EoP_mn\"] = model.loc[period, \"Debt_Outstanding_BoP_mn\"] - model.loc[period, \"Amortization_mn\"]\n",
|
| 117 |
+
" if period < assumptions.project_lifetime_years:\n",
|
| 118 |
+
" model.loc[period + 1, \"Debt_Outstanding_BoP_mn\"] = model.loc[period, \"Debt_Outstanding_EoP_mn\"]\n",
|
| 119 |
+
"\n",
|
| 120 |
+
" model = pl.DataFrame(model).with_columns(\n",
|
| 121 |
+
" # Straight line depreciation\n",
|
| 122 |
+
" Depreciation_mn = pl.when(pl.col(\"Period\") > 0).then(assumptions.capital_cost/1000/assumptions.project_lifetime_years).otherwise(0),\n",
|
| 123 |
+
" ).with_columns(\n",
|
| 124 |
+
" Taxable_Income_mn = pl.col(\"EBITDA_mn\") - pl.col(\"Depreciation_mn\") - pl.col(\"Interest_Expense_mn\"),\n",
|
| 125 |
+
" ).with_columns(\n",
|
| 126 |
+
" Tax_Liability_mn = pl.max_horizontal(0, assumptions.tax_rate * pl.col(\"Taxable_Income_mn\"))\n",
|
| 127 |
+
" ).with_columns(\n",
|
| 128 |
+
" Post_Tax_Net_Equity_Cashflow_mn = pl.when(pl.col(\"Period\") == 0).then(-assumptions.capital_cost/1000 * assumptions.equity_pct_of_capital_cost).otherwise(pl.col(\"EBITDA_mn\") - pl.col(\"Target_Debt_Service_mn\") - pl.col(\"Tax_Liability_mn\"))\n",
|
| 129 |
+
" )\n",
|
| 130 |
+
"\n",
|
| 131 |
+
" # Calculate Post-Tax Equity IRR\n",
|
| 132 |
+
" post_tax_equity_irr = irr(model[\"Post_Tax_Net_Equity_Cashflow_mn\"].to_numpy())\n",
|
| 133 |
+
" if return_model:\n",
|
| 134 |
+
" return model, post_tax_equity_irr\n",
|
| 135 |
+
" return post_tax_equity_irr - assumptions.cost_of_equity"
|
| 136 |
+
]
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"cell_type": "code",
|
| 140 |
+
"execution_count": 99,
|
| 141 |
+
"metadata": {},
|
| 142 |
+
"outputs": [
|
| 143 |
+
{
|
| 144 |
+
"data": {
|
| 145 |
+
"text/plain": [
|
| 146 |
+
"(shape: (21, 19)\n",
|
| 147 |
+
" ┌────────┬────────────┬────────────┬───────────┬───┬───────────┬───────────┬───────────┬───────────┐\n",
|
| 148 |
+
" │ Period ┆ Capacity_M ┆ Capacity_F ┆ LCOE ┆ … ┆ Depreciat ┆ Taxable_I ┆ Tax_Liabi ┆ Post_Tax_ │\n",
|
| 149 |
+
" │ --- ┆ W ┆ actor ┆ --- ┆ ┆ ion_mn ┆ ncome_mn ┆ lity_mn ┆ Net_Equit │\n",
|
| 150 |
+
" │ i64 ┆ --- ┆ --- ┆ f64 ┆ ┆ --- ┆ --- ┆ --- ┆ y_Cashflo │\n",
|
| 151 |
+
" │ ┆ f64 ┆ f64 ┆ ┆ ┆ f64 ┆ f64 ┆ f64 ┆ w_m… │\n",
|
| 152 |
+
" │ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ --- │\n",
|
| 153 |
+
" │ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ f64 │\n",
|
| 154 |
+
" ╞════════╪════════════╪════════════╪═══════════╪═══╪═══════════╪═══════════╪═══════════╪═══════════╡\n",
|
| 155 |
+
" │ 0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ … ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ -2592.9 │\n",
|
| 156 |
+
" │ 1 ┆ 30.0 ┆ 0.097 ┆ 77.609918 ┆ … ┆ 1005.0 ┆ -128.8830 ┆ 0.0 ┆ 363.78484 │\n",
|
| 157 |
+
" │ ┆ ┆ ┆ ┆ ┆ ┆ 02 ┆ ┆ 6 │\n",
|
| 158 |
+
" │ 2 ┆ 30.0 ┆ 0.097 ┆ 77.609918 ┆ … ┆ 1005.0 ┆ -108.3897 ┆ 0.0 ┆ 363.78484 │\n",
|
| 159 |
+
" │ ┆ ┆ ┆ ┆ ┆ ┆ 16 ┆ ┆ 6 │\n",
|
| 160 |
+
" │ 3 ┆ 30.0 ┆ 0.097 ┆ 77.609918 ┆ … ┆ 1005.0 ┆ -87.07669 ┆ 0.0 ┆ 363.78484 │\n",
|
| 161 |
+
" │ ┆ ┆ ┆ ┆ ┆ ┆ 9 ┆ ┆ 6 │\n",
|
| 162 |
+
" │ 4 ┆ 30.0 ┆ 0.097 ┆ 77.609918 ┆ … ┆ 1005.0 ┆ -64.91116 ┆ 0.0 ┆ 363.78484 │\n",
|
| 163 |
+
" │ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 6 │\n",
|
| 164 |
+
" │ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … ┆ … │\n",
|
| 165 |
+
" │ 16 ┆ 30.0 ┆ 0.097 ┆ 77.609918 ┆ … ┆ 1005.0 ┆ 281.46610 ┆ 70.366527 ┆ 293.41831 │\n",
|
| 166 |
+
" │ ┆ ┆ ┆ ┆ ┆ ┆ 8 ┆ ┆ 9 │\n",
|
| 167 |
+
" │ 17 ┆ 30.0 ┆ 0.097 ┆ 77.609918 ┆ … ┆ 1005.0 ┆ 318.37335 ┆ 79.59334 ┆ 284.19150 │\n",
|
| 168 |
+
" │ ┆ ┆ ┆ ┆ ┆ ┆ 8 ┆ ┆ 6 │\n",
|
| 169 |
+
" │ 18 ┆ 30.0 ┆ 0.097 ┆ 77.609918 ┆ … ┆ 1005.0 ┆ 356.75689 ┆ 89.189225 ┆ 274.59562 │\n",
|
| 170 |
+
" │ ┆ ┆ ┆ ┆ ┆ ┆ 9 ┆ ┆ 1 │\n",
|
| 171 |
+
" │ 19 ┆ 30.0 ┆ 0.097 ┆ 77.609918 ┆ … ┆ 1005.0 ┆ 396.67578 ┆ 99.168945 ┆ 264.6159 │\n",
|
| 172 |
+
" │ ┆ ┆ ┆ ┆ ┆ ┆ 1 ┆ ┆ │\n",
|
| 173 |
+
" │ 20 ┆ 30.0 ┆ 0.097 ┆ 77.609918 ┆ … ┆ 1005.0 ┆ 438.19141 ┆ 109.54785 ┆ 254.23699 │\n",
|
| 174 |
+
" │ ┆ ┆ ┆ ┆ ┆ ┆ 8 ┆ 5 ┆ 1 │\n",
|
| 175 |
+
" └────────┴────────────┴────────────┴───────────┴───┴───────────┴───────────┴───────────┴───────────┘,\n",
|
| 176 |
+
" 0.12000008866075423)"
|
| 177 |
+
]
|
| 178 |
+
},
|
| 179 |
+
"execution_count": 99,
|
| 180 |
+
"metadata": {},
|
| 181 |
+
"output_type": "execute_result"
|
| 182 |
+
}
|
| 183 |
+
],
|
| 184 |
+
"source": [
|
| 185 |
+
"breakeven_LCOE = fsolve(partial(calculate_cashflow, assumptions), 44)[0] + 0.0001\n",
|
| 186 |
+
"calculate_cashflow(assumptions, breakeven_LCOE, return_model=True)\n"
|
| 187 |
+
]
|
| 188 |
+
}
|
| 189 |
+
],
|
| 190 |
+
"metadata": {
|
| 191 |
+
"kernelspec": {
|
| 192 |
+
"display_name": "renewable-lcoe",
|
| 193 |
+
"language": "python",
|
| 194 |
+
"name": "renewable-lcoe"
|
| 195 |
+
},
|
| 196 |
+
"language_info": {
|
| 197 |
+
"codemirror_mode": {
|
| 198 |
+
"name": "ipython",
|
| 199 |
+
"version": 3
|
| 200 |
+
},
|
| 201 |
+
"file_extension": ".py",
|
| 202 |
+
"mimetype": "text/x-python",
|
| 203 |
+
"name": "python",
|
| 204 |
+
"nbconvert_exporter": "python",
|
| 205 |
+
"pygments_lexer": "ipython3",
|
| 206 |
+
"version": "3.11.5"
|
| 207 |
+
}
|
| 208 |
+
},
|
| 209 |
+
"nbformat": 4,
|
| 210 |
+
"nbformat_minor": 2
|
| 211 |
+
}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "renewable-lcoe-api"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.11"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"fastapi[standard]>=0.115.5",
|
| 9 |
+
"ipykernel>=6.29.5",
|
| 10 |
+
"pandas>=2.2.3",
|
| 11 |
+
"pip>=24.3.1",
|
| 12 |
+
"polars>=1.13.1",
|
| 13 |
+
"pyarrow>=18.0.0",
|
| 14 |
+
"pydantic>=2.9.2",
|
| 15 |
+
"pyxirr>=0.10.6",
|
| 16 |
+
"ruff>=0.7.4",
|
| 17 |
+
"scipy>=1.14.1",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[dependency-groups]
|
| 21 |
+
dev = [
|
| 22 |
+
"ipykernel>=6.29.5",
|
| 23 |
+
]
|
schema.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Annotated
|
| 2 |
+
from pydantic import BaseModel, Field, computed_field, field_validator, model_validator
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class SolarPVAssumptions(BaseModel):
|
| 6 |
+
capacity_mw: Annotated[float, Field(ge=1, le=1000, title="Capacity (MW)")] = 30
|
| 7 |
+
capacity_factor: Annotated[
|
| 8 |
+
float,
|
| 9 |
+
Field(
|
| 10 |
+
ge=0,
|
| 11 |
+
le=0.6,
|
| 12 |
+
title="Capacity factor (%)",
|
| 13 |
+
description="Capacity factor as a decimal, e.g., 0.2 for 20%",
|
| 14 |
+
),
|
| 15 |
+
] = 0.10
|
| 16 |
+
capital_expenditure_per_mw: Annotated[
|
| 17 |
+
float, Field(ge=1e5, le=1e6, title="Capital expenditure per MW ($/MW)")
|
| 18 |
+
] = 670_000
|
| 19 |
+
o_m_cost_pct_of_capital_cost: Annotated[
|
| 20 |
+
float,
|
| 21 |
+
Field(
|
| 22 |
+
ge=0,
|
| 23 |
+
le=0.5,
|
| 24 |
+
title="O&M Cost Percentage (%)",
|
| 25 |
+
description="O&M cost as a percentage of capital expenditure",
|
| 26 |
+
),
|
| 27 |
+
] = 0.02
|
| 28 |
+
debt_pct_of_capital_cost: Annotated[
|
| 29 |
+
float,
|
| 30 |
+
Field(
|
| 31 |
+
ge=0,
|
| 32 |
+
le=1,
|
| 33 |
+
title="Debt Percentage (%)",
|
| 34 |
+
description="Debt as a percentage of capital expenditure",
|
| 35 |
+
),
|
| 36 |
+
] = 0.8
|
| 37 |
+
equity_pct_of_capital_cost: Annotated[
|
| 38 |
+
float,
|
| 39 |
+
Field(
|
| 40 |
+
ge=0,
|
| 41 |
+
le=1,
|
| 42 |
+
title="Equity Percentage (%)",
|
| 43 |
+
description="Equity as a percentage of capital expenditure",
|
| 44 |
+
),
|
| 45 |
+
] = 0.2
|
| 46 |
+
cost_of_debt: Annotated[
|
| 47 |
+
float,
|
| 48 |
+
Field(
|
| 49 |
+
ge=0,
|
| 50 |
+
le=0.2,
|
| 51 |
+
title="Cost of Debt (%)",
|
| 52 |
+
description="Cost of debt (as a decimal, e.g., 0.05 for 5%)",
|
| 53 |
+
),
|
| 54 |
+
] = 0.05
|
| 55 |
+
cost_of_equity: Annotated[
|
| 56 |
+
float,
|
| 57 |
+
Field(
|
| 58 |
+
ge=0,
|
| 59 |
+
le=0.3,
|
| 60 |
+
title="Cost of Equity (%)",
|
| 61 |
+
description="Cost of equity (as a decimal, e.g., 0.1 for 10%)",
|
| 62 |
+
),
|
| 63 |
+
] = 0.10
|
| 64 |
+
tax_rate: Annotated[
|
| 65 |
+
float,
|
| 66 |
+
Field(
|
| 67 |
+
ge=0,
|
| 68 |
+
le=0.5,
|
| 69 |
+
title="Tax Rate (%)",
|
| 70 |
+
description="Tax rate (as a decimal, e.g., 0.3 for 30%)",
|
| 71 |
+
),
|
| 72 |
+
] = 0.30
|
| 73 |
+
project_lifetime_years: Annotated[
|
| 74 |
+
int,
|
| 75 |
+
Field(
|
| 76 |
+
ge=5,
|
| 77 |
+
le=50,
|
| 78 |
+
title="Project Lifetime (years)",
|
| 79 |
+
description="Project lifetime in years",
|
| 80 |
+
),
|
| 81 |
+
] = 25
|
| 82 |
+
dcsr: Annotated[
|
| 83 |
+
float,
|
| 84 |
+
Field(
|
| 85 |
+
ge=1,
|
| 86 |
+
le=2,
|
| 87 |
+
title="Debt Service Coverage Ratio",
|
| 88 |
+
description="Debt service coverage ratio",
|
| 89 |
+
),
|
| 90 |
+
] = 1.3
|
| 91 |
+
|
| 92 |
+
@model_validator(mode="after")
|
| 93 |
+
def check_sum_of_parts(self):
|
| 94 |
+
if self.debt_pct_of_capital_cost + self.equity_pct_of_capital_cost != 1:
|
| 95 |
+
raise ValueError("Debt and equity percentages must sum to 1")
|
| 96 |
+
return self
|
| 97 |
+
|
| 98 |
+
@computed_field
|
| 99 |
+
@property
|
| 100 |
+
def capital_cost(self) -> Annotated[float,
|
| 101 |
+
Field(title="Capital Cost ($)", description="Total capital cost")]:
|
| 102 |
+
return self.capacity_mw * self.capital_expenditure_per_mw
|
| 103 |
+
|
| 104 |
+
@computed_field
|
| 105 |
+
@property
|
| 106 |
+
def tax_adjusted_WACC(self) -> Annotated[float,
|
| 107 |
+
Field(title="Tax Adjusted WACC (%)",
|
| 108 |
+
description="Tax adjusted weighted average cost of capital")]:
|
| 109 |
+
return (self.debt_pct_of_capital_cost * self.cost_of_debt * (1 - self.tax_rate) +
|
| 110 |
+
self.equity_pct_of_capital_cost * self.cost_of_equity)
|
| 111 |
+
|
| 112 |
+
@computed_field
|
| 113 |
+
@property
|
| 114 |
+
def wacc(self) -> Annotated[float, Field(title="WACC (%)", description="Weighted average cost of capital")]:
|
| 115 |
+
return self.debt_pct_of_capital_cost * self.cost_of_debt + self.equity_pct_of_capital_cost * self.cost_of_equity
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|