Durand D'souza commited on
Commit
43156e5
·
unverified ·
1 Parent(s): c9fe0ae

Improved loading and added extra chart to show more of the cashflow

Browse files
Files changed (2) hide show
  1. model.py +3 -3
  2. ui.py +212 -86
model.py CHANGED
@@ -265,7 +265,7 @@ def calculate_lcoe(
265
  """The LCOE is the breakeven tariff that makes the project NPV zero"""
266
  # Define the objective function
267
  objective_function = partial(calculate_cashflow_for_renewable_project, assumptions)
268
- if iter_count > 50:
269
  raise ValueError(
270
  f"LCOE could not be calculated due to iteration limit (tariff guess: {LCOE_guess})"
271
  )
@@ -274,10 +274,10 @@ def calculate_lcoe(
274
  lcoe = fsolve(objective_function, LCOE_guess)[0] + 0.0001
275
  except ValueError as e:
276
  # Set LCOE lower so that fsolve can find a solution
277
- LCOE_guess = 10
278
  lcoe = calculate_lcoe(assumptions, LCOE_guess, iter_count=iter_count + 1)
279
  except AssertionError as e:
280
  # LCOE is too low
281
- LCOE_guess += 30
282
  lcoe = calculate_lcoe(assumptions, LCOE_guess, iter_count=iter_count + 1)
283
  return lcoe
 
265
  """The LCOE is the breakeven tariff that makes the project NPV zero"""
266
  # Define the objective function
267
  objective_function = partial(calculate_cashflow_for_renewable_project, assumptions)
268
+ if iter_count > 5000:
269
  raise ValueError(
270
  f"LCOE could not be calculated due to iteration limit (tariff guess: {LCOE_guess})"
271
  )
 
274
  lcoe = fsolve(objective_function, LCOE_guess)[0] + 0.0001
275
  except ValueError as e:
276
  # Set LCOE lower so that fsolve can find a solution
277
+ LCOE_guess = 1
278
  lcoe = calculate_lcoe(assumptions, LCOE_guess, iter_count=iter_count + 1)
279
  except AssertionError as e:
280
  # LCOE is too low
281
+ LCOE_guess += 5
282
  lcoe = calculate_lcoe(assumptions, LCOE_guess, iter_count=iter_count + 1)
283
  return lcoe
ui.py CHANGED
@@ -5,11 +5,147 @@ from typing import Annotated, Dict, List, Tuple
5
  from urllib.parse import urlencode
6
  import plotly.express as px
7
  import plotly.graph_objects as go
 
 
 
 
8
  from schema import SolarPVAssumptions
9
  from model import calculate_cashflow_for_renewable_project, calculate_lcoe
10
 
11
 
12
- def process_inputs(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  capacity_mw,
14
  capacity_factor,
15
  capital_expenditure_per_kw,
@@ -23,7 +159,9 @@ def process_inputs(
23
  dcsr,
24
  financing_mode,
25
  request: gr.Request,
26
- ) -> Tuple[Dict, pd.DataFrame]:
 
 
27
  try:
28
  # Convert inputs to SolarPVAssumptions model using named parameters
29
  assumptions = SolarPVAssumptions(
@@ -61,34 +199,19 @@ def process_inputs(
61
  return (
62
  {
63
  "lcoe": lcoe,
 
 
 
 
64
  "api_call": f"{request.request.url.scheme}://{request.request.url.netloc}/solarpv/?{urlencode(assumptions.model_dump())}",
65
  },
66
- (
67
- px.bar(
68
- cashflow_model.assign(
69
- **{
70
- "Debt Outstanding EoP": lambda x: x[
71
- "Debt_Outstanding_EoP_mn"
72
- ]
73
- * 1000
74
- }
75
- ),
76
- x="Period",
77
- y="Debt Outstanding EoP",
78
- )
79
- .add_trace(
80
- go.Scatter(
81
- x=cashflow_model["Period"], y=cashflow_model["EBITDA_mn"] * 1000,
82
- name="EBITDA",
83
- ),
84
-
85
- )
86
- .update_layout(xaxis_title="Year")
87
- ),
88
  adjusted_assumptions.debt_pct_of_capital_cost,
89
  adjusted_assumptions.equity_pct_of_capital_cost,
90
  adjusted_assumptions.dcsr,
91
  styled_model,
 
92
  )
93
 
94
  except Exception as e:
@@ -112,7 +235,9 @@ def get_params(request: gr.Request) -> Dict:
112
  project_lifetime_years: params.project_lifetime_years,
113
  degradation_rate: params.degradation_rate,
114
  dcsr: params.dcsr,
115
- financing_mode: "Target DSCR" if params.targetting_dcsr else "Manual Debt/Equity Split",
 
 
116
  }
117
 
118
 
@@ -154,66 +279,82 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
154
  with gr.Row():
155
  with gr.Column(scale=8):
156
  gr.Markdown("# Solar PV Project Cashflow Model [API](/docs)")
 
157
  with gr.Column(scale=1):
158
  submit_btn = gr.Button("Calculate", variant="primary")
 
159
  share_url = gr.Button(
160
  icon="share.svg",
161
  value="Share assumptions",
162
- size="sm",
163
  variant="secondary",
164
  )
165
  with gr.Row():
166
  with gr.Column():
167
  with gr.Row():
168
- capacity_mw = gr.Slider(value=30,
 
169
  minimum=1,
170
  maximum=1000,
171
  step=10,
172
  label="Capacity (MW)",
173
  )
174
- capacity_factor = gr.Slider(value=0.1,
 
175
  label="Capacity factor (%)",
176
  minimum=0,
177
  maximum=0.6,
178
  step=0.01,
179
  )
180
- project_lifetime_years = gr.Slider(value=25,
 
181
  label="Project Lifetime (years)",
182
  minimum=5,
183
  maximum=50,
184
  step=1,
185
  )
186
- degradation_rate = gr.Slider(value=0.005,
 
187
  label="Degradation Rate (%)",
188
  minimum=0,
189
  maximum=0.05,
190
  step=0.005,
191
  )
192
  with gr.Row():
193
- capital_expenditure_per_kw = gr.Slider(value=670,
 
194
  label="Capital expenditure ($/kW)",
195
  minimum=1e2,
196
  maximum=1e3,
197
  step=10,
198
  )
199
- o_m_cost_pct_of_capital_cost = gr.Slider(value=0.02,
 
200
  label="O&M as % of total cost (%)",
201
  minimum=0,
202
  maximum=0.5,
203
  step=0.01,
204
  )
205
  with gr.Row():
206
- cost_of_debt = gr.Slider(value=0.05,
207
- label="Cost of Debt (%)", minimum=0, maximum=0.5, step=0.01
 
 
 
 
208
  )
209
- cost_of_equity = gr.Slider(value=0.10,
 
210
  label="Cost of Equity (%)",
211
  minimum=0,
212
  maximum=0.5,
213
  step=0.01,
214
  )
215
- tax_rate = gr.Slider(value=0.3,
216
- label="Corporate Tax Rate (%)", minimum=0, maximum=0.5, step=0.01
 
 
 
 
217
  )
218
  with gr.Row():
219
  with gr.Row():
@@ -249,7 +390,10 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
249
 
250
  with gr.Column():
251
  json_output = gr.JSON()
252
- line_chart = gr.Plot()
 
 
 
253
  with gr.Row():
254
  model_output = gr.Matrix(headers=None, max_height=800)
255
 
@@ -269,60 +413,44 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
269
  financing_mode,
270
  ]
271
 
272
- # for component in input_components:
273
- # component.change(
274
- # fn=process_inputs,
275
- # inputs=input_components + [financing_mode],
276
- # outputs=[
277
- # json_output,
278
- # line_chart,
279
- # debt_pct_of_capital_cost,
280
- # equity_pct_of_capital_cost,
281
- # model_output,
282
- # ],
283
- # trigger_mode="always_last",
284
- # )
285
- # Remove individual component change handlers and attach to submit button
286
- submit_btn.click(
287
- fn=process_inputs,
288
- inputs=input_components,
289
- outputs=[
290
- json_output,
291
- line_chart,
292
- debt_pct_of_capital_cost,
293
- equity_pct_of_capital_cost,
294
- dcsr,
295
- model_output,
296
- ],
297
  )
298
 
299
-
300
  json_output.change(
301
  fn=get_share_url,
302
- inputs=[
303
- capacity_mw,
304
- capacity_factor,
305
- capital_expenditure_per_kw,
306
- o_m_cost_pct_of_capital_cost,
307
- debt_pct_of_capital_cost,
308
- cost_of_debt,
309
- cost_of_equity,
310
- tax_rate,
311
- project_lifetime_years,
312
- degradation_rate,
313
- dcsr,
314
- financing_mode,
315
- ],
316
  outputs=share_url,
317
  trigger_mode="always_last",
318
  )
319
 
320
- interface.load(get_params, None, input_components, trigger_mode="always_last")
321
- # Run the model on first load
322
- interface.load(
323
- process_inputs,
324
  inputs=input_components,
325
- outputs=[json_output, line_chart, debt_pct_of_capital_cost, equity_pct_of_capital_cost, dcsr, model_output],
 
 
 
 
 
 
 
 
 
326
  )
327
 
328
  def toggle_financing_inputs(choice):
@@ -330,13 +458,11 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
330
  return {
331
  dcsr: gr.update(interactive=True),
332
  debt_pct_of_capital_cost: gr.update(interactive=False),
333
- equity_pct_of_capital_cost: gr.update(),
334
  }
335
  else:
336
  return {
337
  dcsr: gr.update(interactive=False),
338
  debt_pct_of_capital_cost: gr.update(interactive=True),
339
- equity_pct_of_capital_cost: gr.update(),
340
  }
341
 
342
  financing_mode.change(
@@ -350,5 +476,5 @@ with gr.Blocks(theme="citrus", title="Renewable LCOE API") as interface:
350
  fn=update_equity_from_debt,
351
  inputs=[debt_pct_of_capital_cost],
352
  outputs=[equity_pct_of_capital_cost],
353
- trigger_mode="always_last"
354
  )
 
5
  from urllib.parse import urlencode
6
  import plotly.express as px
7
  import plotly.graph_objects as go
8
+ import plotly.io as pio
9
+ pio.templates.default = "plotly_dark"
10
+
11
+ from plotly.subplots import make_subplots
12
  from schema import SolarPVAssumptions
13
  from model import calculate_cashflow_for_renewable_project, calculate_lcoe
14
 
15
 
16
+ def plot_cashflow(cashflow_model: pd.DataFrame) -> gr.Plot:
17
+ return (
18
+ px.bar(
19
+ cashflow_model.assign(
20
+ **{
21
+ "Debt Outstanding EoP": lambda x: x["Debt_Outstanding_EoP_mn"]
22
+ * 1000
23
+ }
24
+ ),
25
+ x="Period",
26
+ y="Debt Outstanding EoP",
27
+ )
28
+ .add_trace(
29
+ go.Scatter(
30
+ x=cashflow_model["Period"],
31
+ y=cashflow_model["EBITDA_mn"] * 1000,
32
+ name="EBITDA",
33
+ ),
34
+ )
35
+ .update_layout(
36
+ xaxis_title="Year",
37
+ # Legend at top of chart
38
+ legend=dict(
39
+ orientation="h",
40
+ yanchor="bottom",
41
+ y=1.02,
42
+ # xanchor="right",
43
+ ),
44
+ margin=dict(l=50, r=50, t=100, b=50),
45
+ )
46
+ )
47
+
48
+
49
+ def plot_revenues_costs(cashflow_model: pd.DataFrame) -> gr.Plot:
50
+ # Convert the model to a pandas dataframe
51
+ df = cashflow_model
52
+ # Negate the Total_Operating_Costs_mn values
53
+ df["Total Operating Costs"] = -df["Total_Operating_Costs_mn"] * 1000
54
+ df["Total Revenues"] = df["Total_Revenues_mn"] * 1000
55
+ df["Target Debt Service"] = df["Target_Debt_Service_mn"] * 1000
56
+ df["DCSR"] = df["CFADS_mn"] / df["Target_Debt_Service_mn"]
57
+ # Round the values to 4 decimal places
58
+ df["DCSR"] = df["DCSR"].round(4)
59
+
60
+ # Create a new dataframe with the required columns
61
+ plot_df = df[
62
+ [
63
+ "Period",
64
+ "Total Revenues",
65
+ "Total Operating Costs",
66
+ "Target Debt Service",
67
+ ]
68
+ ]
69
+
70
+ # Melt the dataframe to have a long format suitable for plotly express
71
+ plot_df = plot_df.melt(
72
+ id_vars="Period",
73
+ value_vars=[
74
+ "Total Revenues",
75
+ "Total Operating Costs",
76
+ "Target Debt Service",
77
+ ],
78
+ var_name="Type",
79
+ value_name="Amount",
80
+ )
81
+
82
+ # Create a subplots figure to handle multiple axes
83
+ subfig = make_subplots(specs=[[{"secondary_y": True}]])
84
+
85
+ # Plot the bar chart
86
+ fig = px.bar(
87
+ plot_df,
88
+ x="Period",
89
+ y="Amount",
90
+ color="Type",
91
+ barmode="overlay",
92
+ title="Total Revenues and Total Operating Costs",
93
+ )
94
+ subfig.add_trace(fig.data[0], secondary_y=False)
95
+ subfig.add_trace(fig.data[1], secondary_y=False)
96
+ subfig.add_trace(fig.data[2], secondary_y=False)
97
+ # Add line trace for EBITDA
98
+ subfig.add_trace(
99
+ go.Scatter(
100
+ x=df["Period"],
101
+ y=df["EBITDA_mn"] * 1000,
102
+ mode="lines+markers",
103
+ name="EBITDA",
104
+ line=dict(color="green"),
105
+ )
106
+ )
107
+ # Add line trace for post-tax net-equity cashflow
108
+ subfig.add_trace(
109
+ go.Scatter(
110
+ x=df["Period"],
111
+ y=df["Post_Tax_Net_Equity_Cashflow_mn"] * 1000,
112
+ mode="lines+markers",
113
+ name="Post-Tax Net Equity Cashflow",
114
+ line=dict(color="red"),
115
+ )
116
+ )
117
+
118
+ # Add the DCSR line
119
+ subfig.add_trace(
120
+ go.Scatter(
121
+ x=df["Period"],
122
+ y=df["DCSR"],
123
+ mode="lines+markers",
124
+ name="DCSR",
125
+ line=dict(color="purple"),
126
+ ),
127
+ secondary_y=True,
128
+ )
129
+
130
+ subfig.update_layout(
131
+ # Legend at top of chart
132
+ legend=dict(
133
+ orientation="h",
134
+ yanchor="bottom",
135
+ y=1.02,
136
+ # xanchor="right",
137
+ ),
138
+ margin=dict(l=50, r=50, t=130, b=50),
139
+ barmode="overlay",
140
+ title="Total Revenues, Total Operating Costs, and DCSR",
141
+ xaxis_title="Year",
142
+ yaxis_title="Amount",
143
+ yaxis2_title="DCSR",
144
+ )
145
+ return subfig
146
+
147
+
148
+ def trigger_lcoe(
149
  capacity_mw,
150
  capacity_factor,
151
  capital_expenditure_per_kw,
 
159
  dcsr,
160
  financing_mode,
161
  request: gr.Request,
162
+ ) -> Tuple[
163
+ Dict, gr.Plot, gr.Plot, gr.Slider, gr.Number, gr.Slider, gr.Matrix, gr.Markdown
164
+ ]:
165
  try:
166
  # Convert inputs to SolarPVAssumptions model using named parameters
167
  assumptions = SolarPVAssumptions(
 
199
  return (
200
  {
201
  "lcoe": lcoe,
202
+ "post_tax_equity_irr": post_tax_equity_irr,
203
+ "debt_service_coverage_ratio": dcsr,
204
+ "debt_pct_of_capital_cost": adjusted_assumptions.debt_pct_of_capital_cost,
205
+ "equity_pct_of_capital_cost": adjusted_assumptions.debt_pct_of_capital_cost,
206
  "api_call": f"{request.request.url.scheme}://{request.request.url.netloc}/solarpv/?{urlencode(assumptions.model_dump())}",
207
  },
208
+ plot_cashflow(cashflow_model),
209
+ plot_revenues_costs(cashflow_model),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  adjusted_assumptions.debt_pct_of_capital_cost,
211
  adjusted_assumptions.equity_pct_of_capital_cost,
212
  adjusted_assumptions.dcsr,
213
  styled_model,
214
+ gr.Markdown(f"## LCOE: {lcoe:,.2f}"),
215
  )
216
 
217
  except Exception as e:
 
235
  project_lifetime_years: params.project_lifetime_years,
236
  degradation_rate: params.degradation_rate,
237
  dcsr: params.dcsr,
238
+ financing_mode: (
239
+ "Target DSCR" if params.targetting_dcsr else "Manual Debt/Equity Split"
240
+ ),
241
  }
242
 
243
 
 
279
  with gr.Row():
280
  with gr.Column(scale=8):
281
  gr.Markdown("# Solar PV Project Cashflow Model [API](/docs)")
282
+ lcoe_result = gr.Markdown("## LCOE: 0.00")
283
  with gr.Column(scale=1):
284
  submit_btn = gr.Button("Calculate", variant="primary")
285
+ with gr.Column(scale=1):
286
  share_url = gr.Button(
287
  icon="share.svg",
288
  value="Share assumptions",
 
289
  variant="secondary",
290
  )
291
  with gr.Row():
292
  with gr.Column():
293
  with gr.Row():
294
+ capacity_mw = gr.Slider(
295
+ value=30,
296
  minimum=1,
297
  maximum=1000,
298
  step=10,
299
  label="Capacity (MW)",
300
  )
301
+ capacity_factor = gr.Slider(
302
+ value=0.1,
303
  label="Capacity factor (%)",
304
  minimum=0,
305
  maximum=0.6,
306
  step=0.01,
307
  )
308
+ project_lifetime_years = gr.Slider(
309
+ value=25,
310
  label="Project Lifetime (years)",
311
  minimum=5,
312
  maximum=50,
313
  step=1,
314
  )
315
+ degradation_rate = gr.Slider(
316
+ value=0.005,
317
  label="Degradation Rate (%)",
318
  minimum=0,
319
  maximum=0.05,
320
  step=0.005,
321
  )
322
  with gr.Row():
323
+ capital_expenditure_per_kw = gr.Slider(
324
+ value=670,
325
  label="Capital expenditure ($/kW)",
326
  minimum=1e2,
327
  maximum=1e3,
328
  step=10,
329
  )
330
+ o_m_cost_pct_of_capital_cost = gr.Slider(
331
+ value=0.02,
332
  label="O&M as % of total cost (%)",
333
  minimum=0,
334
  maximum=0.5,
335
  step=0.01,
336
  )
337
  with gr.Row():
338
+ cost_of_debt = gr.Slider(
339
+ value=0.05,
340
+ label="Cost of Debt (%)",
341
+ minimum=0,
342
+ maximum=0.5,
343
+ step=0.01,
344
  )
345
+ cost_of_equity = gr.Slider(
346
+ value=0.10,
347
  label="Cost of Equity (%)",
348
  minimum=0,
349
  maximum=0.5,
350
  step=0.01,
351
  )
352
+ tax_rate = gr.Slider(
353
+ value=0.3,
354
+ label="Corporate Tax Rate (%)",
355
+ minimum=0,
356
+ maximum=0.5,
357
+ step=0.01,
358
  )
359
  with gr.Row():
360
  with gr.Row():
 
390
 
391
  with gr.Column():
392
  json_output = gr.JSON()
393
+ with gr.Tab("Revenues and Costs"):
394
+ revenue_cost_chart = gr.Plot()
395
+ with gr.Tab("Debt cashflow"):
396
+ cashflow_bar_chart = gr.Plot()
397
  with gr.Row():
398
  model_output = gr.Matrix(headers=None, max_height=800)
399
 
 
413
  financing_mode,
414
  ]
415
 
416
+ # Trigger calculation with submit button
417
+ gr.on(
418
+ triggers=[submit_btn.click],
419
+ fn=trigger_lcoe,
420
+ inputs=input_components,
421
+ outputs=[
422
+ json_output,
423
+ cashflow_bar_chart,
424
+ revenue_cost_chart,
425
+ debt_pct_of_capital_cost,
426
+ equity_pct_of_capital_cost,
427
+ dcsr,
428
+ model_output,
429
+ lcoe_result,
430
+ ],
 
 
 
 
 
 
 
 
 
 
431
  )
432
 
 
433
  json_output.change(
434
  fn=get_share_url,
435
+ inputs=input_components,
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  outputs=share_url,
437
  trigger_mode="always_last",
438
  )
439
 
440
+ # Load URL parameters into assumptions and then trigger the process_inputs function
441
+ interface.load(get_params, None, input_components, trigger_mode="always_last").then(
442
+ trigger_lcoe,
 
443
  inputs=input_components,
444
+ outputs=[
445
+ json_output,
446
+ cashflow_bar_chart,
447
+ revenue_cost_chart,
448
+ debt_pct_of_capital_cost,
449
+ equity_pct_of_capital_cost,
450
+ dcsr,
451
+ model_output,
452
+ lcoe_result,
453
+ ],
454
  )
455
 
456
  def toggle_financing_inputs(choice):
 
458
  return {
459
  dcsr: gr.update(interactive=True),
460
  debt_pct_of_capital_cost: gr.update(interactive=False),
 
461
  }
462
  else:
463
  return {
464
  dcsr: gr.update(interactive=False),
465
  debt_pct_of_capital_cost: gr.update(interactive=True),
 
466
  }
467
 
468
  financing_mode.change(
 
476
  fn=update_equity_from_debt,
477
  inputs=[debt_pct_of_capital_cost],
478
  outputs=[equity_pct_of_capital_cost],
479
+ trigger_mode="always_last",
480
  )