Durand D'souza commited on
Commit
14ec231
·
unverified ·
1 Parent(s): 82639fb

Added loan tenor option

Browse files
Files changed (4) hide show
  1. main.py +1 -1
  2. model.py +12 -12
  3. schema.py +30 -9
  4. ui.py +98 -37
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 DCSR-sculpted debt % of capital cost if debt % is not provided
92
- if (assumptions.debt_pct_of_capital_cost is None) or assumptions.targetting_dcsr:
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.dcsr),
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:, 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.targetting_dcsr:
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.project_lifetime_years
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.targetting_dcsr:
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.project_lifetime_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_dcsr:
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.dcsr = (
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
- dcsr: Annotated[
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
- targetting_dcsr: Annotated[
94
  bool,
95
  Field(
96
  title="Target DSCR?",
97
- description="Whether to target the DSCR or the debt percentage. If True, the DCSR will be used to calculate the debt percentage.",
98
  ),
99
  ] = True
100
 
101
  @model_validator(mode="after")
102
  def check_sum_of_parts(self):
103
- if not self.targetting_dcsr:
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 empty_str_to_none(cls, values):
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["DCSR"] = df["CFADS_mn"] / df["Target_Debt_Service_mn"]
60
  # Round the values to 4 decimal places
61
- df["DCSR"] = df["DCSR"].round(4)
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 DCSR line
122
  subfig.add_trace(
123
  go.Scatter(
124
  x=df["Period"],
125
- y=df["DCSR"],
126
  mode="lines+markers",
127
- name="DCSR",
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 DCSR",
144
  xaxis_title="Year",
145
  yaxis_title="Amount",
146
- yaxis2_title="DCSR",
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
- dcsr,
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
- dcsr=dcsr,
186
- targetting_dcsr=(financing_mode == "Target DSCR"),
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": dcsr,
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/?{urlencode(assumptions.model_dump())}",
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.dcsr,
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
- dcsr: params.dcsr,
 
241
  financing_mode: (
242
- "Target DSCR" if params.targetting_dcsr else "Manual Debt/Equity Split"
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
- dcsr,
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
- "dcsr": dcsr,
274
- "targetting_dcsr": financing_mode == "Target DSCR",
 
 
 
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
- degradation_rate = gr.Slider(
341
- value=0.005,
342
- label="Degradation Rate (%)",
343
- minimum=0,
344
- maximum=0.05,
345
- step=0.005,
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=1e3,
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
- dcsr = gr.Slider(
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
- dcsr,
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
- dcsr,
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(get_params, None, input_components, trigger_mode="always_last").then(
 
 
 
 
 
 
 
 
 
 
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
- dcsr,
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
- dcsr: gr.update(interactive=True),
493
  debt_pct_of_capital_cost: gr.update(interactive=False),
494
  }
495
  else:
496
  return {
497
- dcsr: gr.update(interactive=False),
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=[dcsr, debt_pct_of_capital_cost, equity_pct_of_capital_cost],
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([estimate_capacity_factor.click, interface.load],
 
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({"latitude": [latitude], "longitude": [longitude], "address": [address]}),
 
 
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
+ )