Skip to content

Commit 66f82ea

Browse files
break out EconomicsSamPreRevenue.py from EconomicsUtils.py
1 parent 00e1b5e commit 66f82ea

File tree

4 files changed

+270
-268
lines changed

4 files changed

+270
-268
lines changed

src/geophires_x/EconomicsSam.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,13 @@
3838
project_payback_period_parameter,
3939
total_capex_parameter_output_parameter,
4040
royalty_cost_output_parameter,
41-
_calculate_pre_revenue_costs_and_cashflow,
41+
)
42+
from geophires_x.EconomicsSamPreRevenue import (
43+
_TOTAL_AFTER_TAX_RETURNS_CASH_FLOW_ROW_NAME,
4244
PreRevenueCostsAndCashflow,
4345
calculate_pre_revenue_costs_and_cashflow,
46+
_calculate_pre_revenue_costs_and_cashflow,
4447
adjust_phased_schedule_to_new_length,
45-
_TOTAL_AFTER_TAX_RETURNS_CASH_FLOW_ROW_NAME,
4648
)
4749
from geophires_x.GeoPHIRESUtils import is_float, is_int, sig_figs, quantity
4850
from geophires_x.OptionList import EconomicModel, EndUseOptions
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from dataclasses import dataclass, field
5+
from typing import Any
6+
7+
import numpy as np
8+
from geophires_x.GeoPHIRESUtils import is_float
9+
from scipy.interpolate.interpolate import interp1d
10+
11+
_TOTAL_AFTER_TAX_RETURNS_CASH_FLOW_ROW_NAME = 'Total after-tax returns ($)'
12+
_IDC_CASH_FLOW_ROW_NAME = 'Debt interest payment ($)'
13+
14+
15+
@dataclass
16+
class PreRevenueCostsAndCashflow:
17+
total_installed_cost_usd: float
18+
construction_financing_cost_usd: float
19+
debt_balance_usd: float
20+
inflation_cost_usd: float = 0.0
21+
22+
pre_revenue_cash_flow_profile: list[list[float | str]] = field(default_factory=list)
23+
24+
@property
25+
def effective_debt_percent(self) -> float:
26+
return self.debt_balance_usd / self.total_installed_cost_usd * 100.0
27+
28+
@property
29+
def total_after_tax_returns_cash_flow_usd(self):
30+
return self.pre_revenue_cash_flow_profile_dict[_TOTAL_AFTER_TAX_RETURNS_CASH_FLOW_ROW_NAME]
31+
32+
@property
33+
def pre_revenue_cash_flow_profile_dict(self) -> dict[str, list[float]]:
34+
"""Maps SAM's row names (str) to a list of pre-revenue values"""
35+
ret = {}
36+
37+
for i in range(len(self.pre_revenue_cash_flow_profile)):
38+
row_name = self.pre_revenue_cash_flow_profile[i][0]
39+
if row_name == '':
40+
continue
41+
42+
row_name = row_name.replace(f'{_CONSTRUCTION_LINE_ITEM_DESIGNATOR} ', '')
43+
44+
row_values = self.pre_revenue_cash_flow_profile[i][1:]
45+
ret[row_name] = row_values
46+
47+
return ret
48+
49+
@property
50+
def interest_during_construction_usd(self) -> float:
51+
return sum(
52+
[float(it) for it in self.pre_revenue_cash_flow_profile_dict[_IDC_CASH_FLOW_ROW_NAME] if is_float(it)]
53+
)
54+
55+
56+
def calculate_pre_revenue_costs_and_cashflow(model: 'Model') -> PreRevenueCostsAndCashflow:
57+
econ = model.economics
58+
if econ.inflrateconstruction.Provided:
59+
pre_revenue_inflation_rate = econ.inflrateconstruction.quantity().to('dimensionless').magnitude
60+
else:
61+
pre_revenue_inflation_rate = econ.RINFL.quantity().to('dimensionless').magnitude
62+
63+
pre_revenue_bond_interest_rate_param = econ.BIR
64+
if econ.bond_interest_rate_during_construction.Provided:
65+
pre_revenue_bond_interest_rate_param = econ.bond_interest_rate_during_construction
66+
pre_revenue_bond_interest_rate = pre_revenue_bond_interest_rate_param.quantity().to('dimensionless').magnitude
67+
68+
construction_years: int = model.surfaceplant.construction_years.value
69+
70+
# Translate from negative year index input value to start-year-0-indexed calculation value
71+
debt_financing_start_year: int = (
72+
construction_years - abs(econ.bond_financing_start_year.value) if econ.bond_financing_start_year.Provided else 0
73+
)
74+
75+
return _calculate_pre_revenue_costs_and_cashflow(
76+
total_overnight_capex_usd=econ.CCap.quantity().to('USD').magnitude,
77+
pre_revenue_years_count=construction_years,
78+
phased_capex_schedule=econ.construction_capex_schedule.value,
79+
pre_revenue_bond_interest_rate=pre_revenue_bond_interest_rate,
80+
inflation_rate=pre_revenue_inflation_rate,
81+
debt_fraction=econ.FIB.quantity().to('dimensionless').magnitude,
82+
debt_financing_start_year=debt_financing_start_year,
83+
logger=model.logger,
84+
)
85+
86+
87+
_CONSTRUCTION_LINE_ITEM_DESIGNATOR = '[construction]'
88+
89+
90+
def _calculate_pre_revenue_costs_and_cashflow(
91+
total_overnight_capex_usd: float,
92+
pre_revenue_years_count: int,
93+
phased_capex_schedule: list[float],
94+
pre_revenue_bond_interest_rate: float,
95+
inflation_rate: float,
96+
debt_fraction: float,
97+
debt_financing_start_year: int,
98+
logger: logging.Logger,
99+
) -> PreRevenueCostsAndCashflow:
100+
"""
101+
Calculates the true capitalized cost and interest during pre-revenue years (exploration/permitting/appraisal,
102+
construction) by simulating a year-by-year phased expenditure with inflation.
103+
104+
Also builds a pre-revenue cash flow profile for constructionrevenue years.
105+
"""
106+
107+
logger.info(f"Using Phased CAPEX Schedule: {phased_capex_schedule}")
108+
109+
current_debt_balance_usd = 0.0
110+
total_capitalized_cost_usd = 0.0
111+
total_interest_accrued_usd = 0.0
112+
total_inflation_cost_usd = 0.0
113+
114+
capex_spend_vec: list[float] = []
115+
equity_spend_vec: list[float] = []
116+
debt_draw_vec: list[float] = []
117+
debt_balance_usd_vec: list[float] = []
118+
interest_accrued_vec: list[float] = []
119+
120+
for year_index in range(pre_revenue_years_count):
121+
base_capex_this_year_usd = total_overnight_capex_usd * phased_capex_schedule[year_index]
122+
123+
inflation_factor = (1.0 + inflation_rate) ** (year_index + 1)
124+
inflation_cost_this_year_usd = base_capex_this_year_usd * (inflation_factor - 1.0)
125+
126+
capex_this_year_usd = base_capex_this_year_usd + inflation_cost_this_year_usd
127+
128+
# Interest is calculated on the opening balance (from previous years' draws)
129+
interest_this_year_usd = current_debt_balance_usd * pre_revenue_bond_interest_rate
130+
131+
debt_fraction_this_year = debt_fraction if year_index >= debt_financing_start_year else 0
132+
new_debt_draw_usd = capex_this_year_usd * debt_fraction_this_year
133+
134+
# Equity spend is the cash portion of CAPEX not funded by new debt
135+
equity_spent_this_year_usd = capex_this_year_usd - new_debt_draw_usd
136+
137+
capex_spend_vec.append(capex_this_year_usd)
138+
equity_spend_vec.append(equity_spent_this_year_usd)
139+
debt_draw_vec.append(new_debt_draw_usd)
140+
interest_accrued_vec.append(interest_this_year_usd)
141+
142+
total_capitalized_cost_usd += capex_this_year_usd + interest_this_year_usd
143+
total_interest_accrued_usd += interest_this_year_usd
144+
total_inflation_cost_usd += inflation_cost_this_year_usd
145+
146+
current_debt_balance_usd += new_debt_draw_usd + interest_this_year_usd
147+
debt_balance_usd_vec.append(current_debt_balance_usd)
148+
149+
logger.info(
150+
f"Phased CAPEX calculation complete: "
151+
f"Total Installed Cost: ${total_capitalized_cost_usd:,.2f}, "
152+
f"Final Debt Balance: ${current_debt_balance_usd:,.2f}, "
153+
f"Total Capitalized Interest: ${total_interest_accrued_usd:,.2f}"
154+
)
155+
156+
pre_revenue_cf_profile: list[list[float | str]] = []
157+
158+
blank_row = [''] * len(capex_spend_vec)
159+
160+
def _rnd(k_, v_: Any) -> Any:
161+
return round(float(v_)) if k_.endswith('($)') and is_float(v_) else v_
162+
163+
def _append_row(row_name: str, row_vals: list[float | str]) -> None:
164+
row_name_adjusted = row_name.split('(')[0] + f'{_CONSTRUCTION_LINE_ITEM_DESIGNATOR} (' + row_name.split('(')[1]
165+
pre_revenue_cf_profile.append([row_name_adjusted] + [_rnd(row_name, it) for it in row_vals])
166+
167+
# --- Investing Activities ---
168+
_append_row(f'Purchase of property ($)', [-x for x in capex_spend_vec])
169+
_append_row(
170+
f'Cash flow from investing activities ($)',
171+
# 'CAPEX spend ($)'
172+
[-x for x in capex_spend_vec],
173+
)
174+
175+
pre_revenue_cf_profile.append(blank_row.copy())
176+
177+
# --- Financing Activities ---
178+
_append_row(
179+
f'Issuance of equity ($)',
180+
[abs(it) for it in equity_spend_vec],
181+
)
182+
183+
_append_row(
184+
# 'Debt draw ($)'
185+
f'Issuance of debt ($)',
186+
debt_draw_vec,
187+
)
188+
189+
_append_row(
190+
f'Debt balance ($)'
191+
# 'Size of debt ($)'
192+
,
193+
debt_balance_usd_vec,
194+
)
195+
196+
_append_row(_IDC_CASH_FLOW_ROW_NAME, interest_accrued_vec)
197+
198+
_append_row(f'Cash flow from financing activities ($)', [e + d for e, d in zip(equity_spend_vec, debt_draw_vec)])
199+
200+
pre_revenue_cf_profile.append(blank_row.copy())
201+
202+
# --- Returns ---
203+
equity_cash_flow_usd = [-x for x in equity_spend_vec]
204+
_append_row(f'Total pre-tax returns ($)', equity_cash_flow_usd)
205+
_append_row(_TOTAL_AFTER_TAX_RETURNS_CASH_FLOW_ROW_NAME, equity_cash_flow_usd)
206+
207+
return PreRevenueCostsAndCashflow(
208+
total_installed_cost_usd=total_capitalized_cost_usd,
209+
construction_financing_cost_usd=total_interest_accrued_usd,
210+
debt_balance_usd=current_debt_balance_usd,
211+
inflation_cost_usd=total_inflation_cost_usd,
212+
# pre_revenue_cash_flow_profile_dict=pre_revenue_cf_profile_dict,
213+
pre_revenue_cash_flow_profile=pre_revenue_cf_profile,
214+
)
215+
216+
217+
def adjust_phased_schedule_to_new_length(original_schedule: list[float], new_length: int) -> list[float]:
218+
"""
219+
Adjusts a schedule (list of fractions) to a new length by interpolation,
220+
then normalizes the result to ensure it sums to 1.0.
221+
222+
Args:
223+
original_schedule: The initial list of fractional values.
224+
new_length: The desired length of the new schedule.
225+
226+
Returns:
227+
A new schedule of the desired length with its values summing to 1.0.
228+
"""
229+
230+
if new_length < 1:
231+
raise ValueError
232+
233+
if not original_schedule:
234+
raise ValueError
235+
236+
original_len = len(original_schedule)
237+
if original_len == new_length:
238+
return original_schedule
239+
240+
if original_len == 1:
241+
# Interpolation is not possible with a single value; return a constant schedule
242+
return [1.0 / new_length] * new_length
243+
244+
# Create an interpolation function based on the original schedule
245+
x_original = np.arange(original_len)
246+
y_original = np.array(original_schedule)
247+
248+
# Use linear interpolation, and extrapolate if the new schedule is longer
249+
f = interp1d(x_original, y_original, kind='nearest', fill_value="extrapolate")
250+
251+
# Create new x-points for the desired length
252+
x_new = np.linspace(0, original_len - 1, new_length)
253+
254+
# Get the new, projected y-values
255+
y_new = f(x_new)
256+
257+
# Normalize the new schedule so it sums to 1.0
258+
total = np.sum(y_new)
259+
if total == 0:
260+
# Avoid division by zero; return an equal distribution
261+
return [1.0 / new_length] * new_length
262+
263+
normalized_schedule = (y_new / total).tolist()
264+
return normalized_schedule

0 commit comments

Comments
 (0)