Spaces:
Sleeping
Sleeping
Durand D'souza
commited on
Added loan tenor option
Browse files
main.py
CHANGED
|
@@ -41,7 +41,7 @@ app.add_middleware(
|
|
| 41 |
# return RedirectResponse(redirect_url)
|
| 42 |
|
| 43 |
|
| 44 |
-
@app.get("/solarpv")
|
| 45 |
def get_lcoe(pv_assumptions: Annotated[SolarPVAssumptions, Query()]):
|
| 46 |
return calculate_lcoe(pv_assumptions)
|
| 47 |
|
|
|
|
| 41 |
# return RedirectResponse(redirect_url)
|
| 42 |
|
| 43 |
|
| 44 |
+
@app.get("/solarpv/lcoe")
|
| 45 |
def get_lcoe(pv_assumptions: Annotated[SolarPVAssumptions, Query()]):
|
| 46 |
return calculate_lcoe(pv_assumptions)
|
| 47 |
|
model.py
CHANGED
|
@@ -88,18 +88,18 @@ def calculate_cashflow_for_renewable_project(
|
|
| 88 |
.with_columns(
|
| 89 |
CFADS_mn=pl.col("EBITDA_mn"),
|
| 90 |
))
|
| 91 |
-
# Calculate
|
| 92 |
-
if (assumptions.debt_pct_of_capital_cost is None) or assumptions.
|
| 93 |
model = (
|
| 94 |
model.with_columns(
|
| 95 |
-
Target_Debt_Service_mn=pl.when(pl.col("Period") == 0)
|
| 96 |
.then(0)
|
| 97 |
-
.otherwise(pl.col("CFADS_mn") / assumptions.
|
| 98 |
)
|
| 99 |
)
|
| 100 |
assumptions.debt_pct_of_capital_cost = pyxirr.npv(
|
| 101 |
assumptions.cost_of_debt,
|
| 102 |
-
model.select("Target_Debt_Service_mn").__array__()[0
|
| 103 |
) / (assumptions.capital_cost / 1000)
|
| 104 |
# print(assumptions.debt_pct_of_capital_cost)
|
| 105 |
# assumptions.equity_pct_of_capital_cost = 1 - assumptions.debt_pct_of_capital_cost
|
|
@@ -133,7 +133,7 @@ def calculate_cashflow_for_renewable_project(
|
|
| 133 |
),
|
| 134 |
))
|
| 135 |
# Calculate amortization, assuming a target DSCR
|
| 136 |
-
if assumptions.
|
| 137 |
model = (
|
| 138 |
model.with_columns(
|
| 139 |
Amortization_mn=pl.when(pl.col("Period") == 0)
|
|
@@ -149,13 +149,13 @@ def calculate_cashflow_for_renewable_project(
|
|
| 149 |
# Calculate amortization, assuming equal amortization over the project lifetime
|
| 150 |
model = (
|
| 151 |
model.with_columns(
|
| 152 |
-
Amortization_mn=pl.when(pl.col("Period") == 0)
|
| 153 |
.then(0)
|
| 154 |
.otherwise(
|
| 155 |
assumptions.debt_pct_of_capital_cost
|
| 156 |
* assumptions.capital_cost
|
| 157 |
/ 1000
|
| 158 |
-
/ assumptions.
|
| 159 |
),
|
| 160 |
))
|
| 161 |
model = (
|
|
@@ -177,7 +177,7 @@ def calculate_cashflow_for_renewable_project(
|
|
| 177 |
model.loc[period, "Interest_Expense_mn"] = (
|
| 178 |
model.loc[period, "Debt_Outstanding_BoP_mn"] * assumptions.cost_of_debt
|
| 179 |
)
|
| 180 |
-
if assumptions.
|
| 181 |
model.loc[period, "Amortization_mn"] = min(
|
| 182 |
model.loc[period, "Target_Debt_Service_mn"]
|
| 183 |
- model.loc[period, "Interest_Expense_mn"],
|
|
@@ -187,12 +187,12 @@ def calculate_cashflow_for_renewable_project(
|
|
| 187 |
model.loc[period, "Debt_Outstanding_BoP_mn"]
|
| 188 |
- model.loc[period, "Amortization_mn"]
|
| 189 |
)
|
| 190 |
-
if period < assumptions.
|
| 191 |
model.loc[period + 1, "Debt_Outstanding_BoP_mn"] = model.loc[
|
| 192 |
period, "Debt_Outstanding_EoP_mn"
|
| 193 |
]
|
| 194 |
model = pl.DataFrame(model)
|
| 195 |
-
if not assumptions.
|
| 196 |
# Target debt service = Amortization + Interest
|
| 197 |
model = (
|
| 198 |
model.with_columns(
|
|
@@ -204,7 +204,7 @@ def calculate_cashflow_for_renewable_project(
|
|
| 204 |
)
|
| 205 |
)
|
| 206 |
# Calculate DSCR
|
| 207 |
-
assumptions.
|
| 208 |
model["EBITDA_mn"] / model["Target_Debt_Service_mn"]
|
| 209 |
).min()
|
| 210 |
|
|
|
|
| 88 |
.with_columns(
|
| 89 |
CFADS_mn=pl.col("EBITDA_mn"),
|
| 90 |
))
|
| 91 |
+
# Calculate DSCR-sculpted debt % of capital cost if debt % is not provided
|
| 92 |
+
if (assumptions.debt_pct_of_capital_cost is None) or assumptions.targetting_dscr:
|
| 93 |
model = (
|
| 94 |
model.with_columns(
|
| 95 |
+
Target_Debt_Service_mn=pl.when((pl.col("Period") == 0) | (pl.col("Period") > assumptions.loan_tenor_years))
|
| 96 |
.then(0)
|
| 97 |
+
.otherwise(pl.col("CFADS_mn") / assumptions.dscr),
|
| 98 |
)
|
| 99 |
)
|
| 100 |
assumptions.debt_pct_of_capital_cost = pyxirr.npv(
|
| 101 |
assumptions.cost_of_debt,
|
| 102 |
+
model.select("Target_Debt_Service_mn").__array__()[0:assumptions.loan_tenor_years+1, 0],
|
| 103 |
) / (assumptions.capital_cost / 1000)
|
| 104 |
# print(assumptions.debt_pct_of_capital_cost)
|
| 105 |
# assumptions.equity_pct_of_capital_cost = 1 - assumptions.debt_pct_of_capital_cost
|
|
|
|
| 133 |
),
|
| 134 |
))
|
| 135 |
# Calculate amortization, assuming a target DSCR
|
| 136 |
+
if assumptions.targetting_dscr:
|
| 137 |
model = (
|
| 138 |
model.with_columns(
|
| 139 |
Amortization_mn=pl.when(pl.col("Period") == 0)
|
|
|
|
| 149 |
# Calculate amortization, assuming equal amortization over the project lifetime
|
| 150 |
model = (
|
| 151 |
model.with_columns(
|
| 152 |
+
Amortization_mn=pl.when((pl.col("Period") == 0) | (pl.col("Period") > assumptions.loan_tenor_years))
|
| 153 |
.then(0)
|
| 154 |
.otherwise(
|
| 155 |
assumptions.debt_pct_of_capital_cost
|
| 156 |
* assumptions.capital_cost
|
| 157 |
/ 1000
|
| 158 |
+
/ assumptions.loan_tenor_years
|
| 159 |
),
|
| 160 |
))
|
| 161 |
model = (
|
|
|
|
| 177 |
model.loc[period, "Interest_Expense_mn"] = (
|
| 178 |
model.loc[period, "Debt_Outstanding_BoP_mn"] * assumptions.cost_of_debt
|
| 179 |
)
|
| 180 |
+
if assumptions.targetting_dscr:
|
| 181 |
model.loc[period, "Amortization_mn"] = min(
|
| 182 |
model.loc[period, "Target_Debt_Service_mn"]
|
| 183 |
- model.loc[period, "Interest_Expense_mn"],
|
|
|
|
| 187 |
model.loc[period, "Debt_Outstanding_BoP_mn"]
|
| 188 |
- model.loc[period, "Amortization_mn"]
|
| 189 |
)
|
| 190 |
+
if period < assumptions.loan_tenor_years:
|
| 191 |
model.loc[period + 1, "Debt_Outstanding_BoP_mn"] = model.loc[
|
| 192 |
period, "Debt_Outstanding_EoP_mn"
|
| 193 |
]
|
| 194 |
model = pl.DataFrame(model)
|
| 195 |
+
if not assumptions.targetting_dscr:
|
| 196 |
# Target debt service = Amortization + Interest
|
| 197 |
model = (
|
| 198 |
model.with_columns(
|
|
|
|
| 204 |
)
|
| 205 |
)
|
| 206 |
# Calculate DSCR
|
| 207 |
+
assumptions.dscr = (
|
| 208 |
model["EBITDA_mn"] / model["Target_Debt_Service_mn"]
|
| 209 |
).min()
|
| 210 |
|
schema.py
CHANGED
|
@@ -72,6 +72,15 @@ class SolarPVAssumptions(BaseModel):
|
|
| 72 |
description="Project lifetime in years",
|
| 73 |
),
|
| 74 |
] = 25
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
degradation_rate: Annotated[
|
| 76 |
float,
|
| 77 |
Field(
|
|
@@ -81,8 +90,8 @@ class SolarPVAssumptions(BaseModel):
|
|
| 81 |
description="Annual degradation rate as a decimal, e.g., 0.01 for 1%",
|
| 82 |
),
|
| 83 |
] = 0.005
|
| 84 |
-
|
| 85 |
-
float,
|
| 86 |
Field(
|
| 87 |
ge=1,
|
| 88 |
le=10,
|
|
@@ -90,17 +99,17 @@ class SolarPVAssumptions(BaseModel):
|
|
| 90 |
description="Debt service coverage ratio",
|
| 91 |
),
|
| 92 |
] = 1.3
|
| 93 |
-
|
| 94 |
bool,
|
| 95 |
Field(
|
| 96 |
title="Target DSCR?",
|
| 97 |
-
description="Whether to target the DSCR or the debt percentage. If True, the
|
| 98 |
),
|
| 99 |
] = True
|
| 100 |
|
| 101 |
@model_validator(mode="after")
|
| 102 |
def check_sum_of_parts(self):
|
| 103 |
-
if not self.
|
| 104 |
assert (
|
| 105 |
self.debt_pct_of_capital_cost is not None
|
| 106 |
), "Debt percentage must be provided"
|
|
@@ -176,6 +185,18 @@ class SolarPVAssumptions(BaseModel):
|
|
| 176 |
}
|
| 177 |
return values
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
class Location(BaseModel):
|
| 180 |
longitude: Annotated[Optional[float], Field(ge=-180, le=180,
|
| 181 |
title="Longitude",
|
|
@@ -190,13 +211,13 @@ class Location(BaseModel):
|
|
| 190 |
|
| 191 |
@model_validator(mode="before")
|
| 192 |
@classmethod
|
| 193 |
-
def
|
| 194 |
"""Convert empty strings or 0s to None"""
|
| 195 |
-
if values.get("address") == "":
|
| 196 |
values["address"] = None
|
| 197 |
-
if values.get("longitude") == 0:
|
| 198 |
values["longitude"] = None
|
| 199 |
-
if values.get("latitude") == 0:
|
| 200 |
values["latitude"] = None
|
| 201 |
return values
|
| 202 |
|
|
|
|
| 72 |
description="Project lifetime in years",
|
| 73 |
),
|
| 74 |
] = 25
|
| 75 |
+
loan_tenor_years: Annotated[
|
| 76 |
+
Optional[int],
|
| 77 |
+
Field(
|
| 78 |
+
ge=5,
|
| 79 |
+
le=50,
|
| 80 |
+
title="Loan Tenor (years)",
|
| 81 |
+
description="Loan tenor in years",
|
| 82 |
+
),
|
| 83 |
+
] = 25
|
| 84 |
degradation_rate: Annotated[
|
| 85 |
float,
|
| 86 |
Field(
|
|
|
|
| 90 |
description="Annual degradation rate as a decimal, e.g., 0.01 for 1%",
|
| 91 |
),
|
| 92 |
] = 0.005
|
| 93 |
+
dscr: Annotated[
|
| 94 |
+
Optional[float],
|
| 95 |
Field(
|
| 96 |
ge=1,
|
| 97 |
le=10,
|
|
|
|
| 99 |
description="Debt service coverage ratio",
|
| 100 |
),
|
| 101 |
] = 1.3
|
| 102 |
+
targetting_dscr: Annotated[
|
| 103 |
bool,
|
| 104 |
Field(
|
| 105 |
title="Target DSCR?",
|
| 106 |
+
description="Whether to target the DSCR or the debt percentage. If True, the DSCR will be used to calculate the debt percentage.",
|
| 107 |
),
|
| 108 |
] = True
|
| 109 |
|
| 110 |
@model_validator(mode="after")
|
| 111 |
def check_sum_of_parts(self):
|
| 112 |
+
if not self.targetting_dscr:
|
| 113 |
assert (
|
| 114 |
self.debt_pct_of_capital_cost is not None
|
| 115 |
), "Debt percentage must be provided"
|
|
|
|
| 185 |
}
|
| 186 |
return values
|
| 187 |
|
| 188 |
+
@model_validator(mode="before")
|
| 189 |
+
@classmethod
|
| 190 |
+
def loan_tenor_less_than_lifetime(cls, values):
|
| 191 |
+
# If loan tenor is not provided, set it to project lifetime
|
| 192 |
+
if values.get("loan_tenor_years") is None or values.get("loan_tenor_years") == "None":
|
| 193 |
+
values["loan_tenor_years"] = values.get("project_lifetime_years")
|
| 194 |
+
# Check that loan tenor is less than or equal to project lifetime
|
| 195 |
+
if values.get("loan_tenor_years") is not None:
|
| 196 |
+
if values.get("loan_tenor_years", 25) > values.get("project_lifetime_years", 25):
|
| 197 |
+
raise ValueError("Loan tenor must be less than or equal to project lifetime")
|
| 198 |
+
return values
|
| 199 |
+
|
| 200 |
class Location(BaseModel):
|
| 201 |
longitude: Annotated[Optional[float], Field(ge=-180, le=180,
|
| 202 |
title="Longitude",
|
|
|
|
| 211 |
|
| 212 |
@model_validator(mode="before")
|
| 213 |
@classmethod
|
| 214 |
+
def zeroes_to_none(cls, values):
|
| 215 |
"""Convert empty strings or 0s to None"""
|
| 216 |
+
if values.get("address") == "" or values.get("address") == "None":
|
| 217 |
values["address"] = None
|
| 218 |
+
if values.get("longitude") == 0 or values.get("longitude") == "None":
|
| 219 |
values["longitude"] = None
|
| 220 |
+
if values.get("latitude") == 0 or values.get("latitude") == "None":
|
| 221 |
values["latitude"] = None
|
| 222 |
return values
|
| 223 |
|
ui.py
CHANGED
|
@@ -56,9 +56,9 @@ def plot_revenues_costs(cashflow_model: pd.DataFrame) -> gr.Plot:
|
|
| 56 |
df["Total Operating Costs"] = -df["Total_Operating_Costs_mn"] * 1000
|
| 57 |
df["Total Revenues"] = df["Total_Revenues_mn"] * 1000
|
| 58 |
df["Target Debt Service"] = df["Target_Debt_Service_mn"] * 1000
|
| 59 |
-
df["
|
| 60 |
# Round the values to 4 decimal places
|
| 61 |
-
df["
|
| 62 |
|
| 63 |
# Create a new dataframe with the required columns
|
| 64 |
plot_df = df[
|
|
@@ -118,13 +118,13 @@ def plot_revenues_costs(cashflow_model: pd.DataFrame) -> gr.Plot:
|
|
| 118 |
)
|
| 119 |
)
|
| 120 |
|
| 121 |
-
# Add the
|
| 122 |
subfig.add_trace(
|
| 123 |
go.Scatter(
|
| 124 |
x=df["Period"],
|
| 125 |
-
y=df["
|
| 126 |
mode="lines+markers",
|
| 127 |
-
name="
|
| 128 |
line=dict(color="purple"),
|
| 129 |
),
|
| 130 |
secondary_y=True,
|
|
@@ -140,10 +140,10 @@ def plot_revenues_costs(cashflow_model: pd.DataFrame) -> gr.Plot:
|
|
| 140 |
),
|
| 141 |
margin=dict(l=50, r=50, t=130, b=50),
|
| 142 |
barmode="overlay",
|
| 143 |
-
title="Total Revenues, Total Operating Costs, and
|
| 144 |
xaxis_title="Year",
|
| 145 |
yaxis_title="Amount",
|
| 146 |
-
yaxis2_title="
|
| 147 |
)
|
| 148 |
return subfig
|
| 149 |
|
|
@@ -158,8 +158,9 @@ def trigger_lcoe(
|
|
| 158 |
cost_of_equity,
|
| 159 |
tax_rate,
|
| 160 |
project_lifetime_years,
|
|
|
|
| 161 |
degradation_rate,
|
| 162 |
-
|
| 163 |
financing_mode,
|
| 164 |
request: gr.Request,
|
| 165 |
) -> Tuple[
|
|
@@ -181,9 +182,10 @@ def trigger_lcoe(
|
|
| 181 |
cost_of_equity=cost_of_equity,
|
| 182 |
tax_rate=tax_rate,
|
| 183 |
project_lifetime_years=project_lifetime_years,
|
|
|
|
| 184 |
degradation_rate=degradation_rate,
|
| 185 |
-
|
| 186 |
-
|
| 187 |
)
|
| 188 |
|
| 189 |
# Calculate the LCOE for the project
|
|
@@ -203,16 +205,16 @@ def trigger_lcoe(
|
|
| 203 |
{
|
| 204 |
"lcoe": lcoe,
|
| 205 |
"post_tax_equity_irr": post_tax_equity_irr,
|
| 206 |
-
"debt_service_coverage_ratio":
|
| 207 |
"debt_pct_of_capital_cost": adjusted_assumptions.debt_pct_of_capital_cost,
|
| 208 |
"equity_pct_of_capital_cost": adjusted_assumptions.debt_pct_of_capital_cost,
|
| 209 |
-
"api_call": f"{request.request.url.scheme}://{request.request.url.netloc}/solarpv
|
| 210 |
},
|
| 211 |
plot_cashflow(cashflow_model),
|
| 212 |
plot_revenues_costs(cashflow_model),
|
| 213 |
adjusted_assumptions.debt_pct_of_capital_cost,
|
| 214 |
adjusted_assumptions.equity_pct_of_capital_cost,
|
| 215 |
-
adjusted_assumptions.
|
| 216 |
styled_model,
|
| 217 |
gr.Markdown(f"## LCOE: {lcoe:,.2f}"),
|
| 218 |
)
|
|
@@ -227,6 +229,13 @@ def update_equity_from_debt(debt_pct):
|
|
| 227 |
|
| 228 |
def get_params(request: gr.Request) -> Dict:
|
| 229 |
params = SolarPVAssumptions.model_validate(dict(request.query_params))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
return {
|
| 231 |
capacity_mw: params.capacity_mw,
|
| 232 |
capacity_factor: params.capacity_factor,
|
|
@@ -236,11 +245,16 @@ def get_params(request: gr.Request) -> Dict:
|
|
| 236 |
cost_of_equity: params.cost_of_equity,
|
| 237 |
tax_rate: params.tax_rate,
|
| 238 |
project_lifetime_years: params.project_lifetime_years,
|
|
|
|
| 239 |
degradation_rate: params.degradation_rate,
|
| 240 |
-
|
|
|
|
| 241 |
financing_mode: (
|
| 242 |
-
"Target DSCR" if params.
|
| 243 |
),
|
|
|
|
|
|
|
|
|
|
| 244 |
}
|
| 245 |
|
| 246 |
|
|
@@ -254,9 +268,13 @@ def get_share_url(
|
|
| 254 |
cost_of_equity,
|
| 255 |
tax_rate,
|
| 256 |
project_lifetime_years,
|
|
|
|
| 257 |
degradation_rate,
|
| 258 |
-
|
| 259 |
financing_mode,
|
|
|
|
|
|
|
|
|
|
| 260 |
request: gr.Request,
|
| 261 |
):
|
| 262 |
params = {
|
|
@@ -269,9 +287,13 @@ def get_share_url(
|
|
| 269 |
"cost_of_equity": cost_of_equity,
|
| 270 |
"tax_rate": tax_rate,
|
| 271 |
"project_lifetime_years": project_lifetime_years,
|
|
|
|
| 272 |
"degradation_rate": degradation_rate,
|
| 273 |
-
"
|
| 274 |
-
"
|
|
|
|
|
|
|
|
|
|
| 275 |
}
|
| 276 |
base_url = "?"
|
| 277 |
return gr.Button(link=base_url + urlencode(params))
|
|
@@ -330,6 +352,14 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
|
|
| 330 |
maximum=0.6,
|
| 331 |
step=0.01,
|
| 332 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
project_lifetime_years = gr.Slider(
|
| 334 |
value=25,
|
| 335 |
label="Project Lifetime (years)",
|
|
@@ -337,19 +367,19 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
|
|
| 337 |
maximum=50,
|
| 338 |
step=1,
|
| 339 |
)
|
| 340 |
-
|
| 341 |
-
value=
|
| 342 |
-
label="
|
| 343 |
-
minimum=
|
| 344 |
-
maximum=
|
| 345 |
-
step=
|
| 346 |
)
|
| 347 |
with gr.Row():
|
| 348 |
capital_expenditure_per_kw = gr.Slider(
|
| 349 |
value=670,
|
| 350 |
label="Capital expenditure ($/kW)",
|
| 351 |
minimum=1e2,
|
| 352 |
-
maximum=
|
| 353 |
step=10,
|
| 354 |
)
|
| 355 |
o_m_cost_pct_of_capital_cost = gr.Slider(
|
|
@@ -398,7 +428,7 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
|
|
| 398 |
interactive=False,
|
| 399 |
precision=2,
|
| 400 |
)
|
| 401 |
-
|
| 402 |
value=1.3,
|
| 403 |
label="Debt Service Coverage Ratio",
|
| 404 |
minimum=1,
|
|
@@ -441,8 +471,9 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
|
|
| 441 |
cost_of_equity,
|
| 442 |
tax_rate,
|
| 443 |
project_lifetime_years,
|
|
|
|
| 444 |
degradation_rate,
|
| 445 |
-
|
| 446 |
financing_mode,
|
| 447 |
]
|
| 448 |
|
|
@@ -457,7 +488,7 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
|
|
| 457 |
revenue_cost_chart,
|
| 458 |
debt_pct_of_capital_cost,
|
| 459 |
equity_pct_of_capital_cost,
|
| 460 |
-
|
| 461 |
model_output,
|
| 462 |
lcoe_result,
|
| 463 |
],
|
|
@@ -465,13 +496,28 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
|
|
| 465 |
|
| 466 |
json_output.change(
|
| 467 |
fn=get_share_url,
|
| 468 |
-
inputs=input_components
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
outputs=share_url,
|
| 470 |
trigger_mode="always_last",
|
| 471 |
)
|
| 472 |
|
| 473 |
# Load URL parameters into assumptions and then trigger the process_inputs function
|
| 474 |
-
interface.load(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
trigger_lcoe,
|
| 476 |
inputs=input_components,
|
| 477 |
outputs=[
|
|
@@ -480,7 +526,7 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
|
|
| 480 |
revenue_cost_chart,
|
| 481 |
debt_pct_of_capital_cost,
|
| 482 |
equity_pct_of_capital_cost,
|
| 483 |
-
|
| 484 |
model_output,
|
| 485 |
lcoe_result,
|
| 486 |
],
|
|
@@ -489,19 +535,19 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
|
|
| 489 |
def toggle_financing_inputs(choice):
|
| 490 |
if choice == "Target DSCR":
|
| 491 |
return {
|
| 492 |
-
|
| 493 |
debt_pct_of_capital_cost: gr.update(interactive=False),
|
| 494 |
}
|
| 495 |
else:
|
| 496 |
return {
|
| 497 |
-
|
| 498 |
debt_pct_of_capital_cost: gr.update(interactive=True),
|
| 499 |
}
|
| 500 |
|
| 501 |
financing_mode.change(
|
| 502 |
fn=toggle_financing_inputs,
|
| 503 |
inputs=[financing_mode],
|
| 504 |
-
outputs=[
|
| 505 |
)
|
| 506 |
|
| 507 |
# Add debt percentage change listener
|
|
@@ -525,7 +571,8 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
|
|
| 525 |
pv_location.address,
|
| 526 |
)
|
| 527 |
|
| 528 |
-
gr.on(
|
|
|
|
| 529 |
fn=get_capacity_factor_from_location,
|
| 530 |
inputs=[latitude, longitude, address],
|
| 531 |
outputs=[capacity_factor, latitude, longitude, address],
|
|
@@ -533,7 +580,9 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
|
|
| 533 |
|
| 534 |
def update_location_plot(latitude, longitude, address):
|
| 535 |
return px.scatter_mapbox(
|
| 536 |
-
pd.DataFrame(
|
|
|
|
|
|
|
| 537 |
lat="latitude",
|
| 538 |
lon="longitude",
|
| 539 |
mapbox_style="carto-darkmatter",
|
|
@@ -544,7 +593,7 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
|
|
| 544 |
mapbox=dict(
|
| 545 |
center=dict(lat=latitude, lon=longitude),
|
| 546 |
zoom=10,
|
| 547 |
-
)
|
| 548 |
)
|
| 549 |
|
| 550 |
gr.on(
|
|
@@ -560,3 +609,15 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
|
|
| 560 |
inputs=[],
|
| 561 |
outputs=[latitude, longitude],
|
| 562 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
df["Total Operating Costs"] = -df["Total_Operating_Costs_mn"] * 1000
|
| 57 |
df["Total Revenues"] = df["Total_Revenues_mn"] * 1000
|
| 58 |
df["Target Debt Service"] = df["Target_Debt_Service_mn"] * 1000
|
| 59 |
+
df["DSCR"] = df["CFADS_mn"] / df["Target_Debt_Service_mn"]
|
| 60 |
# Round the values to 4 decimal places
|
| 61 |
+
df["DSCR"] = df["DSCR"].round(4)
|
| 62 |
|
| 63 |
# Create a new dataframe with the required columns
|
| 64 |
plot_df = df[
|
|
|
|
| 118 |
)
|
| 119 |
)
|
| 120 |
|
| 121 |
+
# Add the DSCR line
|
| 122 |
subfig.add_trace(
|
| 123 |
go.Scatter(
|
| 124 |
x=df["Period"],
|
| 125 |
+
y=df["DSCR"],
|
| 126 |
mode="lines+markers",
|
| 127 |
+
name="DSCR",
|
| 128 |
line=dict(color="purple"),
|
| 129 |
),
|
| 130 |
secondary_y=True,
|
|
|
|
| 140 |
),
|
| 141 |
margin=dict(l=50, r=50, t=130, b=50),
|
| 142 |
barmode="overlay",
|
| 143 |
+
title="Total Revenues, Total Operating Costs, and DSCR",
|
| 144 |
xaxis_title="Year",
|
| 145 |
yaxis_title="Amount",
|
| 146 |
+
yaxis2_title="DSCR",
|
| 147 |
)
|
| 148 |
return subfig
|
| 149 |
|
|
|
|
| 158 |
cost_of_equity,
|
| 159 |
tax_rate,
|
| 160 |
project_lifetime_years,
|
| 161 |
+
loan_tenor_years,
|
| 162 |
degradation_rate,
|
| 163 |
+
dscr,
|
| 164 |
financing_mode,
|
| 165 |
request: gr.Request,
|
| 166 |
) -> Tuple[
|
|
|
|
| 182 |
cost_of_equity=cost_of_equity,
|
| 183 |
tax_rate=tax_rate,
|
| 184 |
project_lifetime_years=project_lifetime_years,
|
| 185 |
+
loan_tenor_years=loan_tenor_years,
|
| 186 |
degradation_rate=degradation_rate,
|
| 187 |
+
dscr=dscr,
|
| 188 |
+
targetting_dscr=(financing_mode == "Target DSCR"),
|
| 189 |
)
|
| 190 |
|
| 191 |
# Calculate the LCOE for the project
|
|
|
|
| 205 |
{
|
| 206 |
"lcoe": lcoe,
|
| 207 |
"post_tax_equity_irr": post_tax_equity_irr,
|
| 208 |
+
"debt_service_coverage_ratio": adjusted_assumptions.dscr,
|
| 209 |
"debt_pct_of_capital_cost": adjusted_assumptions.debt_pct_of_capital_cost,
|
| 210 |
"equity_pct_of_capital_cost": adjusted_assumptions.debt_pct_of_capital_cost,
|
| 211 |
+
"api_call": f"{request.request.url.scheme}://{request.request.url.netloc}/solarpv/lcoe?{urlencode(assumptions.model_dump())}",
|
| 212 |
},
|
| 213 |
plot_cashflow(cashflow_model),
|
| 214 |
plot_revenues_costs(cashflow_model),
|
| 215 |
adjusted_assumptions.debt_pct_of_capital_cost,
|
| 216 |
adjusted_assumptions.equity_pct_of_capital_cost,
|
| 217 |
+
adjusted_assumptions.dscr,
|
| 218 |
styled_model,
|
| 219 |
gr.Markdown(f"## LCOE: {lcoe:,.2f}"),
|
| 220 |
)
|
|
|
|
| 229 |
|
| 230 |
def get_params(request: gr.Request) -> Dict:
|
| 231 |
params = SolarPVAssumptions.model_validate(dict(request.query_params))
|
| 232 |
+
location_params = dict(longitude=request.query_params.get("longitude"), latitude=request.query_params.get("latitude"), address=request.query_params.get("address"))
|
| 233 |
+
for key in location_params:
|
| 234 |
+
if location_params[key] == "None":
|
| 235 |
+
location_params[key] = None
|
| 236 |
+
if location_params[key] is not None and key in ["latitude", "longitude"]:
|
| 237 |
+
location_params[key] = float(location_params[key])
|
| 238 |
+
|
| 239 |
return {
|
| 240 |
capacity_mw: params.capacity_mw,
|
| 241 |
capacity_factor: params.capacity_factor,
|
|
|
|
| 245 |
cost_of_equity: params.cost_of_equity,
|
| 246 |
tax_rate: params.tax_rate,
|
| 247 |
project_lifetime_years: params.project_lifetime_years,
|
| 248 |
+
loan_tenor_years: params.loan_tenor_years if params.loan_tenor_years else params.project_lifetime_years,
|
| 249 |
degradation_rate: params.degradation_rate,
|
| 250 |
+
debt_pct_of_capital_cost: params.debt_pct_of_capital_cost,
|
| 251 |
+
dscr: params.dscr,
|
| 252 |
financing_mode: (
|
| 253 |
+
"Target DSCR" if params.targetting_dscr else "Manual Debt/Equity Split"
|
| 254 |
),
|
| 255 |
+
latitude: location_params["latitude"],
|
| 256 |
+
longitude: location_params["longitude"],
|
| 257 |
+
address: location_params["address"],
|
| 258 |
}
|
| 259 |
|
| 260 |
|
|
|
|
| 268 |
cost_of_equity,
|
| 269 |
tax_rate,
|
| 270 |
project_lifetime_years,
|
| 271 |
+
loan_tenor_years,
|
| 272 |
degradation_rate,
|
| 273 |
+
dscr,
|
| 274 |
financing_mode,
|
| 275 |
+
latitude,
|
| 276 |
+
longitude,
|
| 277 |
+
address,
|
| 278 |
request: gr.Request,
|
| 279 |
):
|
| 280 |
params = {
|
|
|
|
| 287 |
"cost_of_equity": cost_of_equity,
|
| 288 |
"tax_rate": tax_rate,
|
| 289 |
"project_lifetime_years": project_lifetime_years,
|
| 290 |
+
"loan_tenor_years": loan_tenor_years,
|
| 291 |
"degradation_rate": degradation_rate,
|
| 292 |
+
"dscr": dscr,
|
| 293 |
+
"targetting_dscr": financing_mode == "Target DSCR",
|
| 294 |
+
"latitude": latitude if latitude != 51.5074 else None,
|
| 295 |
+
"longitude": longitude if longitude != -0.1278 else None,
|
| 296 |
+
"address": address if address else None,
|
| 297 |
}
|
| 298 |
base_url = "?"
|
| 299 |
return gr.Button(link=base_url + urlencode(params))
|
|
|
|
| 352 |
maximum=0.6,
|
| 353 |
step=0.01,
|
| 354 |
)
|
| 355 |
+
degradation_rate = gr.Slider(
|
| 356 |
+
value=0.005,
|
| 357 |
+
label="Degradation Rate (%)",
|
| 358 |
+
minimum=0,
|
| 359 |
+
maximum=0.05,
|
| 360 |
+
step=0.005,
|
| 361 |
+
)
|
| 362 |
+
with gr.Row():
|
| 363 |
project_lifetime_years = gr.Slider(
|
| 364 |
value=25,
|
| 365 |
label="Project Lifetime (years)",
|
|
|
|
| 367 |
maximum=50,
|
| 368 |
step=1,
|
| 369 |
)
|
| 370 |
+
loan_tenor_years = gr.Slider(
|
| 371 |
+
value=25,
|
| 372 |
+
label="Loan Tenor (years)",
|
| 373 |
+
minimum=5,
|
| 374 |
+
maximum=50,
|
| 375 |
+
step=1,
|
| 376 |
)
|
| 377 |
with gr.Row():
|
| 378 |
capital_expenditure_per_kw = gr.Slider(
|
| 379 |
value=670,
|
| 380 |
label="Capital expenditure ($/kW)",
|
| 381 |
minimum=1e2,
|
| 382 |
+
maximum=5e3,
|
| 383 |
step=10,
|
| 384 |
)
|
| 385 |
o_m_cost_pct_of_capital_cost = gr.Slider(
|
|
|
|
| 428 |
interactive=False,
|
| 429 |
precision=2,
|
| 430 |
)
|
| 431 |
+
dscr = gr.Slider(
|
| 432 |
value=1.3,
|
| 433 |
label="Debt Service Coverage Ratio",
|
| 434 |
minimum=1,
|
|
|
|
| 471 |
cost_of_equity,
|
| 472 |
tax_rate,
|
| 473 |
project_lifetime_years,
|
| 474 |
+
loan_tenor_years,
|
| 475 |
degradation_rate,
|
| 476 |
+
dscr,
|
| 477 |
financing_mode,
|
| 478 |
]
|
| 479 |
|
|
|
|
| 488 |
revenue_cost_chart,
|
| 489 |
debt_pct_of_capital_cost,
|
| 490 |
equity_pct_of_capital_cost,
|
| 491 |
+
dscr,
|
| 492 |
model_output,
|
| 493 |
lcoe_result,
|
| 494 |
],
|
|
|
|
| 496 |
|
| 497 |
json_output.change(
|
| 498 |
fn=get_share_url,
|
| 499 |
+
inputs=input_components
|
| 500 |
+
+ [
|
| 501 |
+
latitude,
|
| 502 |
+
longitude,
|
| 503 |
+
address,
|
| 504 |
+
],
|
| 505 |
outputs=share_url,
|
| 506 |
trigger_mode="always_last",
|
| 507 |
)
|
| 508 |
|
| 509 |
# Load URL parameters into assumptions and then trigger the process_inputs function
|
| 510 |
+
interface.load(
|
| 511 |
+
get_params,
|
| 512 |
+
None,
|
| 513 |
+
input_components
|
| 514 |
+
+ [
|
| 515 |
+
latitude,
|
| 516 |
+
longitude,
|
| 517 |
+
address,
|
| 518 |
+
],
|
| 519 |
+
trigger_mode="always_last",
|
| 520 |
+
).then(
|
| 521 |
trigger_lcoe,
|
| 522 |
inputs=input_components,
|
| 523 |
outputs=[
|
|
|
|
| 526 |
revenue_cost_chart,
|
| 527 |
debt_pct_of_capital_cost,
|
| 528 |
equity_pct_of_capital_cost,
|
| 529 |
+
dscr,
|
| 530 |
model_output,
|
| 531 |
lcoe_result,
|
| 532 |
],
|
|
|
|
| 535 |
def toggle_financing_inputs(choice):
|
| 536 |
if choice == "Target DSCR":
|
| 537 |
return {
|
| 538 |
+
dscr: gr.update(interactive=True),
|
| 539 |
debt_pct_of_capital_cost: gr.update(interactive=False),
|
| 540 |
}
|
| 541 |
else:
|
| 542 |
return {
|
| 543 |
+
dscr: gr.update(interactive=False),
|
| 544 |
debt_pct_of_capital_cost: gr.update(interactive=True),
|
| 545 |
}
|
| 546 |
|
| 547 |
financing_mode.change(
|
| 548 |
fn=toggle_financing_inputs,
|
| 549 |
inputs=[financing_mode],
|
| 550 |
+
outputs=[dscr, debt_pct_of_capital_cost, equity_pct_of_capital_cost],
|
| 551 |
)
|
| 552 |
|
| 553 |
# Add debt percentage change listener
|
|
|
|
| 571 |
pv_location.address,
|
| 572 |
)
|
| 573 |
|
| 574 |
+
gr.on(
|
| 575 |
+
[estimate_capacity_factor.click, interface.load],
|
| 576 |
fn=get_capacity_factor_from_location,
|
| 577 |
inputs=[latitude, longitude, address],
|
| 578 |
outputs=[capacity_factor, latitude, longitude, address],
|
|
|
|
| 580 |
|
| 581 |
def update_location_plot(latitude, longitude, address):
|
| 582 |
return px.scatter_mapbox(
|
| 583 |
+
pd.DataFrame(
|
| 584 |
+
{"latitude": [latitude], "longitude": [longitude], "address": [address]}
|
| 585 |
+
),
|
| 586 |
lat="latitude",
|
| 587 |
lon="longitude",
|
| 588 |
mapbox_style="carto-darkmatter",
|
|
|
|
| 593 |
mapbox=dict(
|
| 594 |
center=dict(lat=latitude, lon=longitude),
|
| 595 |
zoom=10,
|
| 596 |
+
),
|
| 597 |
)
|
| 598 |
|
| 599 |
gr.on(
|
|
|
|
| 609 |
inputs=[],
|
| 610 |
outputs=[latitude, longitude],
|
| 611 |
)
|
| 612 |
+
|
| 613 |
+
# If user changes the project lifetime, set the maximum loan tenor to the project lifetime
|
| 614 |
+
def update_loan_tenor(project_lifetime_years, loan_tenor_years):
|
| 615 |
+
if project_lifetime_years < loan_tenor_years:
|
| 616 |
+
return gr.Slider(value=project_lifetime_years, maximum=project_lifetime_years)
|
| 617 |
+
return gr.Slider(maximum=project_lifetime_years)
|
| 618 |
+
|
| 619 |
+
project_lifetime_years.change(
|
| 620 |
+
fn=update_loan_tenor,
|
| 621 |
+
inputs=[project_lifetime_years, loan_tenor_years],
|
| 622 |
+
outputs=[loan_tenor_years],
|
| 623 |
+
)
|