Durand D'souza commited on
Commit
c9568ab
·
unverified ·
0 Parent(s):

Initial commit of LCOE calculator

Browse files
Files changed (9) hide show
  1. .gitignore +191 -0
  2. .python-version +1 -0
  3. README.md +0 -0
  4. main.py +10 -0
  5. model.py +84 -0
  6. notebook.ipynb +211 -0
  7. pyproject.toml +23 -0
  8. schema.py +115 -0
  9. 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