Spaces:
Sleeping
Sleeping
Durand D'souza
commited on
Enhance cashflow calculation with error handling and add JSON export endpoint
Browse files
main.py
CHANGED
|
@@ -3,6 +3,7 @@ from urllib.parse import urlencode
|
|
| 3 |
from fastapi import FastAPI, Query, Request, Response
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from fastapi.responses import PlainTextResponse, RedirectResponse
|
|
|
|
| 6 |
import pandas as pd
|
| 7 |
from pydantic import Field, model_validator
|
| 8 |
from capacity_factors import get_solar_capacity_factor
|
|
@@ -78,14 +79,31 @@ class CashflowParams(SolarPVAssumptions):
|
|
| 78 |
|
| 79 |
@app.get("/solarpv/cashflow.csv", response_class=PlainTextResponse)
|
| 80 |
def get_cashflow(params: Annotated[CashflowParams, Query()]) -> str:
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
| 88 |
return cashflow.write_csv(float_precision=3)
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
app = gr.mount_gradio_app(app, interface, path="/")
|
|
|
|
| 3 |
from fastapi import FastAPI, Query, Request, Response
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from fastapi.responses import PlainTextResponse, RedirectResponse
|
| 6 |
+
import orjson
|
| 7 |
import pandas as pd
|
| 8 |
from pydantic import Field, model_validator
|
| 9 |
from capacity_factors import get_solar_capacity_factor
|
|
|
|
| 79 |
|
| 80 |
@app.get("/solarpv/cashflow.csv", response_class=PlainTextResponse)
|
| 81 |
def get_cashflow(params: Annotated[CashflowParams, Query()]) -> str:
|
| 82 |
+
try:
|
| 83 |
+
cashflow = calculate_cashflow_for_renewable_project(
|
| 84 |
+
params, tariff=params.tariff, return_model=True, errors="ignore"
|
| 85 |
+
)[0]
|
| 86 |
+
if params.transpose:
|
| 87 |
+
cashflow = cashflow.to_pandas().T
|
| 88 |
+
cashflow.columns = cashflow.loc["Period"].astype(int).astype(str)
|
| 89 |
+
return cashflow.drop(["Period"]).to_csv(float_format="%.3f")
|
| 90 |
+
except Exception as e:
|
| 91 |
+
return str(e)
|
| 92 |
return cashflow.write_csv(float_precision=3)
|
| 93 |
|
| 94 |
+
@app.get("/solarpv/cashflow.json")
|
| 95 |
+
def get_cashflow_json(params: Annotated[CashflowParams, Query()]) -> Dict:
|
| 96 |
+
try:
|
| 97 |
+
cashflow, equity_irr, tariff, adjusted_assumptions = calculate_cashflow_for_renewable_project(
|
| 98 |
+
params, tariff=params.tariff, return_model=True, errors="ignore"
|
| 99 |
+
)
|
| 100 |
+
except Exception as e:
|
| 101 |
+
return {"error": str(e)}
|
| 102 |
+
return {
|
| 103 |
+
"cashflow": cashflow.to_pandas().to_dict(orient="records"),
|
| 104 |
+
"equity_irr": equity_irr,
|
| 105 |
+
"tariff": tariff,
|
| 106 |
+
"assumptions": adjusted_assumptions,
|
| 107 |
+
}
|
| 108 |
|
| 109 |
app = gr.mount_gradio_app(app, interface, path="/")
|
model.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from typing import Annotated, Iterable, Optional, Tuple
|
| 2 |
import numpy as np
|
| 3 |
import polars as pl
|
| 4 |
from pyxirr import irr, npv
|
|
@@ -10,9 +10,9 @@ from schema import SolarPVAssumptions
|
|
| 10 |
|
| 11 |
|
| 12 |
def calculate_cashflow_for_renewable_project(
|
| 13 |
-
assumptions: SolarPVAssumptions, tariff: float | Iterable, return_model=False
|
| 14 |
) -> (
|
| 15 |
-
Annotated[
|
| 16 |
| Tuple[
|
| 17 |
Annotated[pl.DataFrame, "Cashflow model"],
|
| 18 |
Annotated[float | None, "Post-tax equity IRR"],
|
|
@@ -28,8 +28,11 @@ def calculate_cashflow_for_renewable_project(
|
|
| 28 |
return_model (bool, optional): Whether to return the model. Defaults to False.
|
| 29 |
|
| 30 |
Returns:
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
| 33 |
"""
|
| 34 |
|
| 35 |
# Tariff must be a number
|
|
@@ -97,12 +100,11 @@ def calculate_cashflow_for_renewable_project(
|
|
| 97 |
.otherwise(pl.col("CFADS_mn") / assumptions.dscr),
|
| 98 |
)
|
| 99 |
)
|
| 100 |
-
|
|
|
|
| 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
|
| 106 |
assert (
|
| 107 |
assumptions.debt_pct_of_capital_cost
|
| 108 |
+ assumptions.equity_pct_of_capital_cost
|
|
@@ -240,18 +242,28 @@ def calculate_cashflow_for_renewable_project(
|
|
| 240 |
)
|
| 241 |
)
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
# Calculate Post-Tax Equity IRR
|
| 244 |
try:
|
| 245 |
post_tax_equity_irr = irr(model["Post_Tax_Net_Equity_Cashflow_mn"].to_numpy())
|
| 246 |
except pyxirr.InvalidPaymentsError as e:
|
| 247 |
-
if
|
| 248 |
-
|
| 249 |
-
"The project is fully financed by debt so equity IRR is infinite."
|
| 250 |
-
)
|
| 251 |
else:
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
if return_model:
|
| 257 |
return model, post_tax_equity_irr, tariff, assumptions
|
|
|
|
| 1 |
+
from typing import Annotated, Iterable, Literal, Optional, Tuple
|
| 2 |
import numpy as np
|
| 3 |
import polars as pl
|
| 4 |
from pyxirr import irr, npv
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def calculate_cashflow_for_renewable_project(
|
| 13 |
+
assumptions: SolarPVAssumptions, tariff: float | Iterable, return_model=False, errors: Literal["raise", "ignore"] = "raise"
|
| 14 |
) -> (
|
| 15 |
+
Annotated[float | None, "Post-tax equity IRR - Cost of equity"]
|
| 16 |
| Tuple[
|
| 17 |
Annotated[pl.DataFrame, "Cashflow model"],
|
| 18 |
Annotated[float | None, "Post-tax equity IRR"],
|
|
|
|
| 28 |
return_model (bool, optional): Whether to return the model. Defaults to False.
|
| 29 |
|
| 30 |
Returns:
|
| 31 |
+
float: post-tax equity IRR - cost of equity (if return_model is False)
|
| 32 |
+
pl.DataFrame: Cashflow model (if return_model is True)
|
| 33 |
+
float: Post-tax equity IRR (if return_model is True)
|
| 34 |
+
float: Breakeven tariff (if return_model is True)
|
| 35 |
+
SolarPVAssumptions: Assumptions (if return_model is True)
|
| 36 |
"""
|
| 37 |
|
| 38 |
# Tariff must be a number
|
|
|
|
| 100 |
.otherwise(pl.col("CFADS_mn") / assumptions.dscr),
|
| 101 |
)
|
| 102 |
)
|
| 103 |
+
# Calculate debt % of capital cost by calculating NPV of debt service, and then dividing by capital cost
|
| 104 |
+
assumptions.debt_pct_of_capital_cost = min(1, pyxirr.npv(
|
| 105 |
assumptions.cost_of_debt,
|
| 106 |
model.select("Target_Debt_Service_mn").__array__()[0:assumptions.loan_tenor_years+1, 0],
|
| 107 |
+
) / (assumptions.capital_cost / 1000))
|
|
|
|
|
|
|
| 108 |
assert (
|
| 109 |
assumptions.debt_pct_of_capital_cost
|
| 110 |
+ assumptions.equity_pct_of_capital_cost
|
|
|
|
| 242 |
)
|
| 243 |
)
|
| 244 |
|
| 245 |
+
## Do some sanity checks
|
| 246 |
+
# Check that the debt outstanding at the end of the loan period is zero
|
| 247 |
+
assert (
|
| 248 |
+
(model["Debt_Outstanding_EoP_mn"].slice(assumptions.loan_tenor_years,) < 0.0001).all() # type: ignore
|
| 249 |
+
), f"Debt outstanding at the end of the loan period is not zero: {model['Debt_Outstanding_EoP_mn'].slice(assumptions.loan_tenor_years,)}" # type: ignore
|
| 250 |
+
|
| 251 |
+
|
| 252 |
# Calculate Post-Tax Equity IRR
|
| 253 |
try:
|
| 254 |
post_tax_equity_irr = irr(model["Post_Tax_Net_Equity_Cashflow_mn"].to_numpy())
|
| 255 |
except pyxirr.InvalidPaymentsError as e:
|
| 256 |
+
if errors == "ignore":
|
| 257 |
+
post_tax_equity_irr = None
|
|
|
|
|
|
|
| 258 |
else:
|
| 259 |
+
if assumptions.debt_pct_of_capital_cost == 1:
|
| 260 |
+
raise AssertionError(
|
| 261 |
+
"The project is fully financed by debt so equity IRR is infinite."
|
| 262 |
+
)
|
| 263 |
+
else:
|
| 264 |
+
raise AssertionError(
|
| 265 |
+
f"The power tariff is too low so the project never breaks even. Please increase it from {tariff}."
|
| 266 |
+
)
|
| 267 |
|
| 268 |
if return_model:
|
| 269 |
return model, post_tax_equity_irr, tariff, assumptions
|
schema.py
CHANGED
|
@@ -37,7 +37,7 @@ class SolarPVAssumptions(BaseModel):
|
|
| 37 |
),
|
| 38 |
] = None
|
| 39 |
cost_of_debt: Annotated[
|
| 40 |
-
float,
|
| 41 |
Field(
|
| 42 |
ge=0,
|
| 43 |
le=0.5,
|
|
@@ -46,7 +46,7 @@ class SolarPVAssumptions(BaseModel):
|
|
| 46 |
),
|
| 47 |
] = 0.05
|
| 48 |
cost_of_equity: Annotated[
|
| 49 |
-
float,
|
| 50 |
Field(
|
| 51 |
ge=0,
|
| 52 |
le=0.5,
|
|
@@ -117,6 +117,11 @@ class SolarPVAssumptions(BaseModel):
|
|
| 117 |
raise ValueError("Debt and equity percentages must sum to 1")
|
| 118 |
return self
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
@computed_field
|
| 121 |
@property
|
| 122 |
def capital_cost(
|
|
|
|
| 37 |
),
|
| 38 |
] = None
|
| 39 |
cost_of_debt: Annotated[
|
| 40 |
+
Optional[float],
|
| 41 |
Field(
|
| 42 |
ge=0,
|
| 43 |
le=0.5,
|
|
|
|
| 46 |
),
|
| 47 |
] = 0.05
|
| 48 |
cost_of_equity: Annotated[
|
| 49 |
+
Optional[float],
|
| 50 |
Field(
|
| 51 |
ge=0,
|
| 52 |
le=0.5,
|
|
|
|
| 117 |
raise ValueError("Debt and equity percentages must sum to 1")
|
| 118 |
return self
|
| 119 |
|
| 120 |
+
@model_validator(mode="before")
|
| 121 |
+
@classmethod
|
| 122 |
+
def remove_none_values(cls, values):
|
| 123 |
+
return {k: v for k, v in values.items() if v is not None}
|
| 124 |
+
|
| 125 |
@computed_field
|
| 126 |
@property
|
| 127 |
def capital_cost(
|