dwind 0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dwind/__init__.py +3 -0
- dwind/btm_sizing.py +129 -0
- dwind/config.py +118 -0
- dwind/helper.py +172 -0
- dwind/loader.py +59 -0
- dwind/model.py +371 -0
- dwind/mp.py +225 -0
- dwind/resource.py +166 -0
- dwind/run.py +288 -0
- dwind/scenarios.py +139 -0
- dwind/valuation.py +1562 -0
- dwind-0.3.dist-info/METADATA +168 -0
- dwind-0.3.dist-info/RECORD +17 -0
- dwind-0.3.dist-info/WHEEL +5 -0
- dwind-0.3.dist-info/entry_points.txt +2 -0
- dwind-0.3.dist-info/licenses/LICENSE.txt +29 -0
- dwind-0.3.dist-info/top_level.txt +1 -0
dwind/valuation.py
ADDED
@@ -0,0 +1,1562 @@
|
|
1
|
+
import time
|
2
|
+
import logging
|
3
|
+
import functools
|
4
|
+
import concurrent.futures as cf
|
5
|
+
|
6
|
+
import h5py as h5
|
7
|
+
import numpy as np
|
8
|
+
import pandas as pd
|
9
|
+
import PySAM.Battery as battery
|
10
|
+
import PySAM.Cashloan as cashloan
|
11
|
+
import PySAM.BatteryTools as battery_tools
|
12
|
+
import PySAM.Utilityrate5 as ur5
|
13
|
+
import PySAM.Merchantplant as mp
|
14
|
+
from scipy import optimize
|
15
|
+
|
16
|
+
from dwind import Configuration, loader, scenarios
|
17
|
+
|
18
|
+
|
19
|
+
log = logging.getLogger("dwfs")
|
20
|
+
|
21
|
+
|
22
|
+
class ValueFunctions:
|
23
|
+
def __init__(
|
24
|
+
self,
|
25
|
+
scenario: str,
|
26
|
+
year: int,
|
27
|
+
configuration: Configuration,
|
28
|
+
return_format="totals",
|
29
|
+
):
|
30
|
+
"""Primary model calculation engine responsible for the computation of individual agents.
|
31
|
+
|
32
|
+
Args:
|
33
|
+
scenario (str): Only option is "baseline" currently.
|
34
|
+
year (int): Analysis year.
|
35
|
+
configuration (dwind.config.Configuration): Model configuration with universal settings.
|
36
|
+
return_format ['profiles', 'total_profile', 'totals', 'total'] -
|
37
|
+
Return individual value stream 8760s, a cumulative value stream 8760,
|
38
|
+
annual totals for each value stream, or a single cumulative total
|
39
|
+
"""
|
40
|
+
self.scenario = scenario
|
41
|
+
self.year = year
|
42
|
+
self.config = configuration
|
43
|
+
self.return_format = return_format
|
44
|
+
|
45
|
+
self.CAMBIUM_SCENARIO = scenarios.config_cambium(self.scenario)
|
46
|
+
self.COST_INPUTS = scenarios.config_costs(self.scenario, self.year)
|
47
|
+
self.PERFORMANCE_INPUTS = scenarios.config_performance(self.scenario, self.year)
|
48
|
+
self.FINANCIAL_INPUTS = scenarios.config_financial(self.scenario, self.year)
|
49
|
+
|
50
|
+
self.load()
|
51
|
+
|
52
|
+
def load(self):
|
53
|
+
_load_csv = functools.partial(loader.load_df, year=self.year)
|
54
|
+
_load_sql = functools.partial(
|
55
|
+
loader.load_df,
|
56
|
+
year=self.year,
|
57
|
+
sql_constructur=self.confg.sql.ATLAS_PG_CON_STR,
|
58
|
+
)
|
59
|
+
|
60
|
+
self.retail_rate_inputs = _load_csv(self.config.cost.RETAIL_RATE_INPUT_TABLE)
|
61
|
+
self.wholesale_rate_inputs = _load_csv(self.config.cost.WHOLESALE_RATE_INPUT_TABLE)
|
62
|
+
self.depreciation_schedule_inputs = _load_csv(self.config.cost.DEPREC_INPUTS_TABLE)
|
63
|
+
|
64
|
+
if "wind" in self.config.project.settings.TECHS:
|
65
|
+
self.wind_price_inputs = _load_sql(self.config.cost.WIND_PRICE_INPUT_TABLE)
|
66
|
+
self.wind_tech_inputs = _load_sql(self.config.cost.WIND_TECH_INPUT_TABLE)
|
67
|
+
self.wind_derate_inputs = _load_sql(self.config.cost.WIND_DERATE_INPUT_TABLE)
|
68
|
+
|
69
|
+
if "solar" in self.config.project.settings.TECHS:
|
70
|
+
self.pv_price_inputs = _load_sql(self.config.cost.PV_PRICE_INPUT_TABLE)
|
71
|
+
self.pv_tech_inputs = _load_sql(self.config.cost.PV_TECH_INPUT_TABLE)
|
72
|
+
self.pv_plus_batt_price_inputs = _load_sql(
|
73
|
+
self.config.cost.PV_PLUS_BATT_PRICE_INPUT_TABLE
|
74
|
+
)
|
75
|
+
|
76
|
+
self.batt_price_inputs = _load_sql(self.config.cost.BATT_PRICE_INPUT_TABLE)
|
77
|
+
self.batt_tech_inputs = _load_sql(self.config.cost.BATT_TECH_INPUT_TABLE)
|
78
|
+
|
79
|
+
def _process_btm_costs(self, cost_inputs: dict, tech: str) -> pd.DataFrame:
|
80
|
+
"""Convert the BTM dictionary data into a dataframe.
|
81
|
+
|
82
|
+
Args:
|
83
|
+
cost_inputs (dict): The BTM portion of the ATB cost dictionary.
|
84
|
+
tech (str): One of "wind" or "solar".
|
85
|
+
|
86
|
+
Returns:
|
87
|
+
(pd.DataFrame): A reformatted data frame of the dictionary with either a
|
88
|
+
"sector_abbr" column or "wind_turbine_kw_btm" for joining on the agent
|
89
|
+
data, and "system_om_per_kw", "system_capex_per_kw",
|
90
|
+
"system_variable_om_per_kw", and "cap_cost_multiplier" columns.
|
91
|
+
"""
|
92
|
+
capex = pd.DataFrame.from_dict(
|
93
|
+
cost_inputs["system_capex_per_kw"]["wind"], orient="index"
|
94
|
+
).reset_index()
|
95
|
+
opex = pd.DataFrame.from_dict(
|
96
|
+
cost_inputs["system_om_per_kw"]["wind"], orient="index"
|
97
|
+
).reset_index()
|
98
|
+
if tech == "solar":
|
99
|
+
capex.columns = ["sector_abbr", "system_capex_per_kw"]
|
100
|
+
opex.columns = ["sector_abbr", "system_om_per_kw"]
|
101
|
+
costs = pd.merge(capex, opex, how="left", left_on="sector_abbr", right_on="sector_abbr")
|
102
|
+
else:
|
103
|
+
capex.columns = ["wind_turbine_kw_btm", "system_capex_per_kw"]
|
104
|
+
opex.columns = ["wind_turbine_kw_btm", "system_om_per_kw"]
|
105
|
+
costs = pd.merge(
|
106
|
+
capex,
|
107
|
+
opex,
|
108
|
+
how="left",
|
109
|
+
left_on="wind_turbine_kw_btm",
|
110
|
+
right_on="wind_turbine_kw_btm",
|
111
|
+
)
|
112
|
+
costs.wind_turbine_kw_btm = costs.wind_turbine_kw_btm.astype(float)
|
113
|
+
|
114
|
+
costs["cap_cost_multiplier"] = cost_inputs["cap_cost_multiplier"][tech]
|
115
|
+
costs["system_variable_om_per_kw"] = cost_inputs["system_variable_om_per_kw"][tech]
|
116
|
+
return costs
|
117
|
+
|
118
|
+
def _preprocess_btm(self, df, tech="wind"):
|
119
|
+
# sec = row['sector_abbr']
|
120
|
+
# county = int(row['county_id'])
|
121
|
+
df["county_id_int"] = df.county_id.astype(int)
|
122
|
+
|
123
|
+
# Get the electricity rates
|
124
|
+
df = pd.merge(
|
125
|
+
df,
|
126
|
+
self.retail_rate_inputs[["county_id", "sector_abbr", "elec_price_multiplier"]],
|
127
|
+
how="left",
|
128
|
+
left_on=["county_id_int", "sector_abbr"],
|
129
|
+
right_on=["county_id", "sector_abbr"],
|
130
|
+
)
|
131
|
+
df = pd.merge(
|
132
|
+
df,
|
133
|
+
self.wholesale_rate_inputs[["county_id", "wholesale_elec_price_dollars_per_kwh"]],
|
134
|
+
how="left",
|
135
|
+
left_on="county_id_int",
|
136
|
+
right_on="county_id",
|
137
|
+
)
|
138
|
+
df = df.drop(columns="county_id_int")
|
139
|
+
|
140
|
+
# Technology-specific factors
|
141
|
+
tech_join = "sector_abbr" if tech == "solar" else "wind_turbine_kw_btm"
|
142
|
+
cost_df = self._process_btm_costs(self.COST_INPUTS["BTM"], tech)
|
143
|
+
df = pd.merge(
|
144
|
+
df,
|
145
|
+
cost_df,
|
146
|
+
how="left",
|
147
|
+
left_on=tech_join,
|
148
|
+
right_on=tech_join,
|
149
|
+
)
|
150
|
+
if tech == "solar":
|
151
|
+
df = pd.merge(
|
152
|
+
df,
|
153
|
+
self.PERFORMANCE_INPUTS[tech],
|
154
|
+
how="left",
|
155
|
+
left_on=tech_join,
|
156
|
+
right_on=tech_join,
|
157
|
+
)
|
158
|
+
else:
|
159
|
+
df = pd.merge(
|
160
|
+
df,
|
161
|
+
self.wind_tech_inputs[["turbine_size_kw", "perf_improvement_factor"]],
|
162
|
+
how="left",
|
163
|
+
left_on="wind_turbine_kw_btm",
|
164
|
+
right_on="turbine_size_kw",
|
165
|
+
)
|
166
|
+
df = pd.merge(
|
167
|
+
df,
|
168
|
+
self.wind_derate_inputs[["turbine_size_kw", "wind_derate_factor"]],
|
169
|
+
how="left",
|
170
|
+
left_on="wind_turbine_kw_btm",
|
171
|
+
right_on="turbine_size_kw",
|
172
|
+
)
|
173
|
+
df["system_degradation"] = 0 # wind degradation already accounted for
|
174
|
+
|
175
|
+
# Financial factors
|
176
|
+
# For 2025, the incentives JSON data are located in the itc_fraction_of_capex
|
177
|
+
# field, and need to be removed, then rejoined with the appropriate column names
|
178
|
+
financial = self.FINANCIAL_INPUTS["BTM"].copy()
|
179
|
+
if self.year == 2025:
|
180
|
+
incentives = pd.DataFrame.from_dict(
|
181
|
+
self.FINANCIAL_INPUTS["BTM"].pop("itc_fraction_of_capex")
|
182
|
+
).T
|
183
|
+
incentives.index.name = "census_tract_id"
|
184
|
+
|
185
|
+
deprec_sch = pd.DataFrame()
|
186
|
+
deprec_sch["sector_abbr"] = financial["deprec_sch"].keys()
|
187
|
+
deprec_sch["deprec_sch"] = financial["deprec_sch"].values()
|
188
|
+
df = pd.merge(
|
189
|
+
df,
|
190
|
+
deprec_sch,
|
191
|
+
how="left",
|
192
|
+
left_on="sector_abbr",
|
193
|
+
right_on="sector_abbr",
|
194
|
+
)
|
195
|
+
df = df.assign(
|
196
|
+
economic_lifetime_yrs=financial["economic_lifetime_yrs"],
|
197
|
+
loan_term_yrs=financial["loan_term_yrs"],
|
198
|
+
loan_interest_rate=financial["loan_interest_rate"],
|
199
|
+
down_payment_fraction=financial["down_payment_fraction"],
|
200
|
+
real_discount_rate=financial["real_discount_rate"],
|
201
|
+
tax_rate=financial["tax_rate"],
|
202
|
+
inflation_rate=financial["inflation_rate"],
|
203
|
+
elec_price_escalator=financial["elec_price_escalator"],
|
204
|
+
itc_fraction_of_capex=0.3,
|
205
|
+
)
|
206
|
+
|
207
|
+
if self.year == 2025:
|
208
|
+
df = df.set_index("census_tract_id", drop=False).join(incentives).reset_index(drop=True)
|
209
|
+
df["itc_fraction_of_capex"] = df.applicable_credit.fillna(0.3)
|
210
|
+
|
211
|
+
df = pd.merge(
|
212
|
+
df,
|
213
|
+
self.batt_price_inputs[
|
214
|
+
[
|
215
|
+
"sector_abbr",
|
216
|
+
"batt_replace_frac_kw",
|
217
|
+
"batt_replace_frac_kwh",
|
218
|
+
"batt_capex_per_kwh",
|
219
|
+
"batt_capex_per_kw",
|
220
|
+
"linear_constant",
|
221
|
+
"batt_om_per_kw",
|
222
|
+
"batt_om_per_kwh",
|
223
|
+
]
|
224
|
+
],
|
225
|
+
how="left",
|
226
|
+
left_on="sector_abbr",
|
227
|
+
right_on="sector_abbr",
|
228
|
+
)
|
229
|
+
df = pd.merge(
|
230
|
+
df,
|
231
|
+
self.batt_tech_inputs[["sector_abbr", "batt_eff", "batt_lifetime_yrs"]],
|
232
|
+
how="left",
|
233
|
+
left_on="sector_abbr",
|
234
|
+
right_on="sector_abbr",
|
235
|
+
)
|
236
|
+
|
237
|
+
return df
|
238
|
+
|
239
|
+
def _preprocess_fom(self, df, tech="wind"):
|
240
|
+
columns = [
|
241
|
+
"yr",
|
242
|
+
"cambium_scenario",
|
243
|
+
"analysis_period",
|
244
|
+
"debt_option",
|
245
|
+
"debt_percent",
|
246
|
+
"inflation_rate",
|
247
|
+
"dscr",
|
248
|
+
"real_discount_rate",
|
249
|
+
"term_int_rate",
|
250
|
+
"term_tenor",
|
251
|
+
f"ptc_fed_amt_{tech}",
|
252
|
+
"itc_fed_pct",
|
253
|
+
"deg",
|
254
|
+
"system_capex_per_kw",
|
255
|
+
"system_om_per_kw",
|
256
|
+
]
|
257
|
+
|
258
|
+
itc_fraction_of_capex = self.FINANCIAL_INPUTS["FOM"]["itc_fraction_of_capex"]
|
259
|
+
values = [
|
260
|
+
self.year,
|
261
|
+
self.CAMBIUM_SCENARIO,
|
262
|
+
self.FINANCIAL_INPUTS["FOM"]["system_lifetime"],
|
263
|
+
self.FINANCIAL_INPUTS["FOM"]["debt_option"],
|
264
|
+
self.FINANCIAL_INPUTS["FOM"]["debt_percent"] * 100,
|
265
|
+
self.FINANCIAL_INPUTS["FOM"]["inflation"] * 100,
|
266
|
+
self.FINANCIAL_INPUTS["FOM"]["dscr"],
|
267
|
+
self.FINANCIAL_INPUTS["FOM"]["discount_rate"] * 100,
|
268
|
+
self.FINANCIAL_INPUTS["FOM"]["interest_rate"] * 100,
|
269
|
+
self.FINANCIAL_INPUTS["FOM"]["system_lifetime"],
|
270
|
+
self.FINANCIAL_INPUTS["FOM"]["ptc_fed_dlrs_per_kwh"][tech],
|
271
|
+
itc_fraction_of_capex if self.year != 2025 else 0.3,
|
272
|
+
self.FINANCIAL_INPUTS["FOM"]["degradation"],
|
273
|
+
self.COST_INPUTS["FOM"]["system_capex_per_kw"][tech],
|
274
|
+
self.COST_INPUTS["FOM"]["system_om_per_kw"][tech],
|
275
|
+
]
|
276
|
+
df[columns] = values
|
277
|
+
# 2025 uses census-tract based applicable credit for the itc_fed_pct, so update accordingly
|
278
|
+
if self.year == 2025:
|
279
|
+
incentives = pd.DataFrame.from_dict(
|
280
|
+
self.FINANCIAL_INPUTS["FOM"]["itc_fraction_of_capex"]
|
281
|
+
).T
|
282
|
+
incentives.index.name = "census_tract_id"
|
283
|
+
df = df.set_index("census_tract_id", drop=False).join(incentives).reset_index(drop=True)
|
284
|
+
df.itc_fed_pct = df.applicable_credit
|
285
|
+
df.itc_fed_pct = df.itc_fed_pct.fillna(0.3)
|
286
|
+
|
287
|
+
return df
|
288
|
+
|
289
|
+
def run(self, agents: pd.DataFrame, sector: str):
|
290
|
+
# self._connect_to_sql()
|
291
|
+
|
292
|
+
max_w = self.config.project.settings.THREAD_WORKERS
|
293
|
+
verb = self.config.project.settings.VERBOSITY
|
294
|
+
|
295
|
+
if max_w > 1:
|
296
|
+
results_list = []
|
297
|
+
|
298
|
+
# btw, multithreading is NOT multiprocessing
|
299
|
+
with cf.ThreadPoolExecutor(max_workers=max_w) as executor:
|
300
|
+
# log.info(
|
301
|
+
# f'....beginning multiprocess execution of valuation with {max_w} threads')
|
302
|
+
log.info(f"....beginning execution of valuation with {max_w} threads")
|
303
|
+
|
304
|
+
start = time.time()
|
305
|
+
checkpoint = max(1, int(len(agents) * verb))
|
306
|
+
|
307
|
+
# submit to worker
|
308
|
+
futures = [
|
309
|
+
executor.submit(self.worker, job, sector, self.config)
|
310
|
+
for _, job in agents.iterrows()
|
311
|
+
]
|
312
|
+
|
313
|
+
# return results *as completed* - not in same order as input
|
314
|
+
for f in cf.as_completed(futures):
|
315
|
+
results_list.append(f.result())
|
316
|
+
if len(results_list) % checkpoint == 0:
|
317
|
+
sec_per_agent = (time.time() - start) / len(results_list)
|
318
|
+
sec_per_agent = round(sec_per_agent, 3)
|
319
|
+
|
320
|
+
eta = (sec_per_agent * (len(agents) - len(results_list))) / 60 / 60
|
321
|
+
eta = round(eta, 2)
|
322
|
+
|
323
|
+
l_results = len(results_list)
|
324
|
+
l_agents = len(agents)
|
325
|
+
|
326
|
+
log.info(f"........finished job {l_results} / {l_agents}")
|
327
|
+
log.info(f"{sec_per_agent} seconds per agent")
|
328
|
+
log.info(f"ETA: {eta} hours")
|
329
|
+
else:
|
330
|
+
results_list = [self.worker(job, sector, self.config) for _, job in agents.iterrows()]
|
331
|
+
|
332
|
+
# create results df from workers
|
333
|
+
new_index = [r[0] for r in results_list]
|
334
|
+
new_dicts = [r[1] for r in results_list]
|
335
|
+
|
336
|
+
new_df = pd.DataFrame(new_dicts)
|
337
|
+
new_df["gid"] = new_index
|
338
|
+
new_df.set_index("gid", inplace=True)
|
339
|
+
|
340
|
+
# merge valuation results to agents dataframe
|
341
|
+
agents = agents.merge(new_df, on="gid", how="left")
|
342
|
+
|
343
|
+
return agents
|
344
|
+
|
345
|
+
def run_multiprocessing(self, agents, sector):
|
346
|
+
# uses cf.ProcessPoolExecutor rather than cf.ThreadPoolExecutor in run.py
|
347
|
+
if sector == "btm":
|
348
|
+
agents = self._preprocess_btm(agents)
|
349
|
+
else:
|
350
|
+
agents = self._preprocess_fom(agents)
|
351
|
+
|
352
|
+
max_w = self.config.project.settings.CORES
|
353
|
+
verb = self.config.project.settings.VERBOSITY
|
354
|
+
|
355
|
+
if max_w > 1:
|
356
|
+
results_list = []
|
357
|
+
|
358
|
+
with cf.ProcessPoolExecutor(max_workers=max_w) as executor:
|
359
|
+
log.info(f"....beginning multiprocess execution of valuation with {max_w} cores")
|
360
|
+
|
361
|
+
start = time.time()
|
362
|
+
checkpoint = max(1, int(len(agents) * verb))
|
363
|
+
|
364
|
+
# submit to worker
|
365
|
+
futures = [
|
366
|
+
executor.submit(worker, job, sector, self.config)
|
367
|
+
for _, job in agents.iterrows()
|
368
|
+
]
|
369
|
+
|
370
|
+
# return results *as completed* - not in same order as input
|
371
|
+
for f in cf.as_completed(futures):
|
372
|
+
results_list.append(f.result())
|
373
|
+
|
374
|
+
if len(results_list) % checkpoint == 0:
|
375
|
+
sec_per_agent = (time.time() - start) / len(results_list)
|
376
|
+
sec_per_agent = round(sec_per_agent, 3)
|
377
|
+
|
378
|
+
eta = (sec_per_agent * (len(agents) - len(results_list))) / 60 / 60
|
379
|
+
eta = round(eta, 2)
|
380
|
+
|
381
|
+
l_results = len(results_list)
|
382
|
+
l_agents = len(agents)
|
383
|
+
|
384
|
+
log.info(f"........finished job {l_results} / {l_agents}")
|
385
|
+
log.info(f"{sec_per_agent} seconds per agent")
|
386
|
+
log.info(f"ETA: {eta} hours")
|
387
|
+
else:
|
388
|
+
results_list = [worker(job, sector, self.config) for _, job in agents.iterrows()]
|
389
|
+
|
390
|
+
# create results df from workers
|
391
|
+
new_index = [r[0] for r in results_list]
|
392
|
+
new_dicts = [r[1] for r in results_list]
|
393
|
+
|
394
|
+
new_df = pd.DataFrame(new_dicts)
|
395
|
+
new_df["gid"] = new_index
|
396
|
+
new_df.set_index("gid", inplace=True)
|
397
|
+
|
398
|
+
# merge valuation results to agents dataframe
|
399
|
+
agents = agents.merge(new_df, on="gid", how="left")
|
400
|
+
|
401
|
+
return agents
|
402
|
+
|
403
|
+
|
404
|
+
def calc_financial_performance(capex_usd_p_kw, row, loan, batt_costs):
|
405
|
+
system_costs = capex_usd_p_kw * row["system_size_kw"]
|
406
|
+
|
407
|
+
# calculate system costs
|
408
|
+
direct_costs = (system_costs + batt_costs) * row["cap_cost_multiplier"]
|
409
|
+
sales_tax = 0.0
|
410
|
+
loan.SystemCosts.total_installed_cost = direct_costs + sales_tax
|
411
|
+
|
412
|
+
# execute financial module
|
413
|
+
loan.execute()
|
414
|
+
|
415
|
+
return loan.Outputs.npv
|
416
|
+
|
417
|
+
|
418
|
+
def calc_financial_performance_fom(capex_usd_p_kw, row, financial):
|
419
|
+
system_costs = capex_usd_p_kw * row.loc["system_size_kw"]
|
420
|
+
|
421
|
+
financial.SystemCosts.total_installed_cost = system_costs
|
422
|
+
financial.FinancialParameters.construction_financing_cost = system_costs * 0.009
|
423
|
+
|
424
|
+
financial.execute(1)
|
425
|
+
|
426
|
+
return financial.Outputs.project_return_aftertax_npv
|
427
|
+
|
428
|
+
|
429
|
+
def find_cf_from_rev_wind(rev_dir, generation_scale_offset, tech_config, rev_index, year=2012):
|
430
|
+
file_str = rev_dir / f"rev_{tech_config}_generation_{year}.h5"
|
431
|
+
|
432
|
+
with h5.File(file_str, "r") as hf:
|
433
|
+
cf_prof = np.array(hf["cf_profile"][:, int(rev_index)], dtype="float32")
|
434
|
+
scale_factor = hf["cf_profile"].attrs.get("scale_factor")
|
435
|
+
|
436
|
+
if len(cf_prof) == 17520:
|
437
|
+
cf_prof = cf_prof[::2]
|
438
|
+
|
439
|
+
if scale_factor is None:
|
440
|
+
scale_factor = generation_scale_offset
|
441
|
+
|
442
|
+
cf_prof /= scale_factor
|
443
|
+
|
444
|
+
return cf_prof
|
445
|
+
|
446
|
+
|
447
|
+
def fetch_cambium_values(row, generation_hourly, cambium_dir, cambium_value, lower_thresh=0.01):
|
448
|
+
# read processed cambium dataframe from pickle
|
449
|
+
cambium_f = cambium_dir / f"{row['cambium_scenario']}_pca_{row['yr']}_processed.pqt"
|
450
|
+
cambium_df = pd.read_parquet(cambium_f)
|
451
|
+
|
452
|
+
cambium_df["year"] = cambium_df["year"].astype(str)
|
453
|
+
cambium_df["pca"] = cambium_df["pca"].astype(str)
|
454
|
+
cambium_df["variable"] = cambium_df["variable"].astype(str)
|
455
|
+
|
456
|
+
# filter on pca, desired year, cambium variable
|
457
|
+
mask = (
|
458
|
+
(cambium_df["year"] == str(row["yr"]))
|
459
|
+
& (cambium_df["pca"] == str(row["ba"]))
|
460
|
+
& (cambium_df["variable"] == cambium_value)
|
461
|
+
)
|
462
|
+
|
463
|
+
cambium_output = cambium_df[mask]
|
464
|
+
cambium_output = cambium_output.reset_index(drop=True)
|
465
|
+
cambium_output = cambium_output["value"].values[0]
|
466
|
+
|
467
|
+
# duplicate gen and cambium_output * analysis_period
|
468
|
+
analysis_period = row["analysis_period"]
|
469
|
+
generation_hourly = list(generation_hourly) * analysis_period
|
470
|
+
cambium_output = list(cambium_output) * analysis_period
|
471
|
+
|
472
|
+
rev = pd.DataFrame(columns=["gen", "value"])
|
473
|
+
rev["value"] = np.array(cambium_output, dtype=np.float16)
|
474
|
+
rev["gen"] = generation_hourly
|
475
|
+
rev["cleared"] = rev["gen"] / 1000 # kW to MW
|
476
|
+
|
477
|
+
# clip minimum output to 1% of maximum output so merchantplant works
|
478
|
+
rev.loc[rev["cleared"] < rev["cleared"].max() * lower_thresh, "cleared"] = 0
|
479
|
+
rev["cleared"] = rev["cleared"].apply(np.floor)
|
480
|
+
|
481
|
+
rev = rev[["cleared", "value"]]
|
482
|
+
tup = tuple(map(tuple, rev.values))
|
483
|
+
|
484
|
+
return tup
|
485
|
+
|
486
|
+
|
487
|
+
def process_tariff(utilityrate, row, net_billing_sell_rate):
|
488
|
+
"""Instantiate the utilityrate5 PySAM model and process the agent's
|
489
|
+
rate information to conform with PySAM input formatting.
|
490
|
+
|
491
|
+
Parameters
|
492
|
+
----------
|
493
|
+
agent : 'pd.Series'
|
494
|
+
Individual agent object.
|
495
|
+
|
496
|
+
Returns:
|
497
|
+
-------
|
498
|
+
utilityrate: 'PySAM.Utilityrate5'
|
499
|
+
"""
|
500
|
+
# Monthly fixed charge [$]
|
501
|
+
utilityrate.ElectricityRates.ur_monthly_fixed_charge = row["ur_monthly_fixed_charge"]
|
502
|
+
|
503
|
+
# Annual minimum charge [$]
|
504
|
+
# not currently tracked in URDB rate attribute downloads
|
505
|
+
utilityrate.ElectricityRates.ur_annual_min_charge = 0.0
|
506
|
+
|
507
|
+
# Monthly minimum charge [$]
|
508
|
+
utilityrate.ElectricityRates.ur_monthly_min_charge = row["ur_monthly_min_charge"]
|
509
|
+
|
510
|
+
# enable demand charge
|
511
|
+
utilityrate.ElectricityRates.ur_dc_enable = row["ur_dc_enable"]
|
512
|
+
|
513
|
+
# create matrix for demand charges
|
514
|
+
if utilityrate.ElectricityRates.ur_dc_enable:
|
515
|
+
if row["ur_dc_flat_mat"] is not None:
|
516
|
+
flat_mat = [list(x) for x in row["ur_dc_flat_mat"]]
|
517
|
+
utilityrate.ElectricityRates.ur_dc_flat_mat = flat_mat
|
518
|
+
if row["ur_dc_tou_mat"] is not None:
|
519
|
+
tou_mat = [list(x) for x in row["ur_dc_tou_mat"]]
|
520
|
+
utilityrate.ElectricityRates.ur_dc_tou_mat = tou_mat
|
521
|
+
|
522
|
+
d_wkdy_mat = [list(x) for x in row["ur_dc_sched_weekday"]]
|
523
|
+
utilityrate.ElectricityRates.ur_dc_sched_weekday = d_wkdy_mat
|
524
|
+
|
525
|
+
# energy charge weekend schedule
|
526
|
+
d_wknd_mat = [list(x) for x in row["ur_dc_sched_weekday"]]
|
527
|
+
utilityrate.ElectricityRates.ur_dc_sched_weekend = d_wknd_mat
|
528
|
+
|
529
|
+
# create matrix for energy charges
|
530
|
+
if row["en_electricity_rates"]:
|
531
|
+
# energy rates table
|
532
|
+
ec_mat = []
|
533
|
+
for x in row["ur_ec_tou_mat"]:
|
534
|
+
x = x.copy()
|
535
|
+
x[-1] = net_billing_sell_rate
|
536
|
+
ec_mat.append(list(x))
|
537
|
+
utilityrate.ElectricityRates.ur_ec_tou_mat = ec_mat
|
538
|
+
|
539
|
+
# energy charge weekday schedule
|
540
|
+
wkdy_mat = [list(x) for x in row["ur_ec_sched_weekday"]]
|
541
|
+
utilityrate.ElectricityRates.ur_ec_sched_weekday = wkdy_mat
|
542
|
+
|
543
|
+
# energy charge weekend schedule
|
544
|
+
wknd_mat = [list(x) for x in row["ur_ec_sched_weekday"]]
|
545
|
+
utilityrate.ElectricityRates.ur_ec_sched_weekend = wknd_mat
|
546
|
+
|
547
|
+
return utilityrate
|
548
|
+
|
549
|
+
|
550
|
+
def find_breakeven(
|
551
|
+
row,
|
552
|
+
loan,
|
553
|
+
batt_costs,
|
554
|
+
pysam_outputs,
|
555
|
+
pre_calc_bounds_and_tolerances=True,
|
556
|
+
**kwargs,
|
557
|
+
):
|
558
|
+
# calculate theoretical min/max NPV values
|
559
|
+
min_npv = calc_financial_performance(1e9, row, loan, batt_costs)
|
560
|
+
|
561
|
+
if min_npv > 0.0:
|
562
|
+
# if "infinite" cost system yields positive NPV
|
563
|
+
# then system economical at any price - return 1e9 as flag
|
564
|
+
out = 1e9
|
565
|
+
return out, {"msg": "Inf cost w/ positive NPV"}
|
566
|
+
|
567
|
+
max_npv = calc_financial_performance(0.0, row, loan, batt_costs)
|
568
|
+
|
569
|
+
if max_npv < 0.0:
|
570
|
+
# if zero-cost system yields negative NPV
|
571
|
+
# then no breakeven cost exists - return -1 as flag
|
572
|
+
out = -1.0
|
573
|
+
return out, {"msg": "Zero cost w/ negative NPV"}
|
574
|
+
|
575
|
+
# pre-calculate bounds for bisect and brentq methods
|
576
|
+
if pre_calc_bounds_and_tolerances:
|
577
|
+
pre_results = []
|
578
|
+
pre_capex_array = np.logspace(9, 0, num=500)
|
579
|
+
|
580
|
+
for capex_usd_p_kw in pre_capex_array:
|
581
|
+
pre_npv = calc_financial_performance(capex_usd_p_kw, row, loan, batt_costs)
|
582
|
+
pre_results.append(pre_npv)
|
583
|
+
|
584
|
+
pre_results = np.asarray(pre_results)
|
585
|
+
|
586
|
+
# find index where NPV flips from negative to positive
|
587
|
+
ind = np.where(np.diff(np.sign(pre_results)))[0][0]
|
588
|
+
|
589
|
+
# get lower (price with negative NPV) and upper (price with positive NPV) bounds
|
590
|
+
a = pre_capex_array[ind]
|
591
|
+
b = pre_capex_array[ind + 1]
|
592
|
+
|
593
|
+
# depending on magnitude of capex values,
|
594
|
+
# tolerance FOR NEWTON METHOD ONLY can be too small
|
595
|
+
# anecdotally, ratio of tol:capex should be ~ 1e-6
|
596
|
+
# pre-calculate tolerance as a proportion of capex value pre-calculated directly above
|
597
|
+
tol = 1e-6 * ((a + b) / 2)
|
598
|
+
|
599
|
+
if kwargs["method"] == "grid_search":
|
600
|
+
if kwargs["capex_array"] is None:
|
601
|
+
capex_array = np.arange(5000.0, -500.0, -500.0)
|
602
|
+
else:
|
603
|
+
capex_array = kwargs["capex_array"]
|
604
|
+
|
605
|
+
try:
|
606
|
+
results = []
|
607
|
+
for capex_usd_p_kw in capex_array:
|
608
|
+
npv = calc_financial_performance(capex_usd_p_kw, row, loan, batt_costs)
|
609
|
+
results.append([capex_usd_p_kw, npv])
|
610
|
+
|
611
|
+
out = pd.DataFrame(results, columns=["capex_usd_p_kw", "npv"])
|
612
|
+
|
613
|
+
return out, {"msg": "Grid search cannot return additional PySAM outputs"}
|
614
|
+
|
615
|
+
except Exception as e:
|
616
|
+
raise ValueError("Grid search failed.") from e
|
617
|
+
elif kwargs["method"] == "bisect":
|
618
|
+
try:
|
619
|
+
# required args for 'bisect'
|
620
|
+
if not pre_calc_bounds_and_tolerances:
|
621
|
+
a = kwargs["a"]
|
622
|
+
b = kwargs["b"]
|
623
|
+
|
624
|
+
# optional args for 'bisect'
|
625
|
+
xtol = kwargs["xtol"] if "xtol" in kwargs.keys() else 2e-12
|
626
|
+
rtol = kwargs["rtol"] if "rtol" in kwargs.keys() else 4 * np.finfo(float).eps
|
627
|
+
maxiter = kwargs["maxiter"] if "maxiter" in kwargs.keys() else 100
|
628
|
+
full_output = kwargs["full_output"] if "full_output" in kwargs.keys() else False
|
629
|
+
disp = kwargs["disp"] if "disp" in kwargs.keys() else True
|
630
|
+
except Exception as e:
|
631
|
+
raise ValueError("Make sure method-specific inputs are specified correctly.") from e
|
632
|
+
|
633
|
+
try:
|
634
|
+
breakeven_cost_usd_p_kw, r = optimize.bisect(
|
635
|
+
calc_financial_performance,
|
636
|
+
args=(row, loan, batt_costs),
|
637
|
+
a=a,
|
638
|
+
b=b,
|
639
|
+
xtol=xtol,
|
640
|
+
rtol=rtol,
|
641
|
+
maxiter=maxiter,
|
642
|
+
full_output=full_output,
|
643
|
+
disp=disp,
|
644
|
+
)
|
645
|
+
|
646
|
+
return breakeven_cost_usd_p_kw, {
|
647
|
+
k: loan.Outputs.export().get(k, None) for k in pysam_outputs
|
648
|
+
}
|
649
|
+
|
650
|
+
except Exception as e:
|
651
|
+
raise ValueError("Root finding failed.") from e
|
652
|
+
elif kwargs["method"] == "brentq":
|
653
|
+
try:
|
654
|
+
# required args for 'brentq'
|
655
|
+
if not pre_calc_bounds_and_tolerances:
|
656
|
+
a = kwargs["a"]
|
657
|
+
b = kwargs["b"]
|
658
|
+
|
659
|
+
# optional args for 'brentq'
|
660
|
+
xtol = kwargs["xtol"] if "xtol" in kwargs.keys() else 2e-12
|
661
|
+
rtol = kwargs["rtol"] if "rtol" in kwargs.keys() else 4 * np.finfo(float).eps
|
662
|
+
maxiter = kwargs["maxiter"] if "maxiter" in kwargs.keys() else 100
|
663
|
+
full_output = kwargs["full_output"] if "full_output" in kwargs.keys() else False
|
664
|
+
disp = kwargs["disp"] if "disp" in kwargs.keys() else True
|
665
|
+
except Exception as e:
|
666
|
+
raise ValueError("Make sure method-specific inputs are specified correctly.") from e
|
667
|
+
|
668
|
+
try:
|
669
|
+
breakeven_cost_usd_p_kw, r = optimize.brentq(
|
670
|
+
calc_financial_performance,
|
671
|
+
args=(row, loan, batt_costs),
|
672
|
+
a=a,
|
673
|
+
b=b,
|
674
|
+
xtol=xtol,
|
675
|
+
rtol=rtol,
|
676
|
+
maxiter=maxiter,
|
677
|
+
full_output=full_output,
|
678
|
+
disp=disp,
|
679
|
+
)
|
680
|
+
|
681
|
+
return breakeven_cost_usd_p_kw, {
|
682
|
+
k: loan.Outputs.export().get(k, None) for k in pysam_outputs
|
683
|
+
}
|
684
|
+
|
685
|
+
except Exception as e:
|
686
|
+
raise ValueError("Root finding failed.") from e
|
687
|
+
|
688
|
+
elif kwargs["method"] == "newton":
|
689
|
+
try:
|
690
|
+
# required args for 'newton'
|
691
|
+
x0 = kwargs["x0"]
|
692
|
+
|
693
|
+
if not pre_calc_bounds_and_tolerances:
|
694
|
+
tol = kwargs["tol"] if "tol" in kwargs.keys() else 1.48e-08
|
695
|
+
|
696
|
+
# --- optional args for 'newton' ---
|
697
|
+
fprime = kwargs["fprime"] if "fprime" in kwargs.keys() else None
|
698
|
+
maxiter = kwargs["maxiter"] if "maxiter" in kwargs.keys() else 50
|
699
|
+
fprime2 = kwargs["fprime2"] if "fprime2" in kwargs.keys() else None
|
700
|
+
x1 = kwargs["x1"] if "x1" in kwargs.keys() else None
|
701
|
+
rtol = kwargs["rtol"] if "rtol" in kwargs.keys() else 0.0
|
702
|
+
full_output = kwargs["full_output"] if "full_output" in kwargs.keys() else False
|
703
|
+
disp = kwargs["disp"] if "disp" in kwargs.keys() else True
|
704
|
+
except Exception as e:
|
705
|
+
raise ValueError("Make sure method-specific inputs are specified correctly.") from e
|
706
|
+
|
707
|
+
try:
|
708
|
+
breakeven_cost_usd_p_kw, r = optimize.newton(
|
709
|
+
calc_financial_performance,
|
710
|
+
args=(row, loan, batt_costs),
|
711
|
+
x0=x0,
|
712
|
+
fprime=fprime,
|
713
|
+
tol=tol,
|
714
|
+
maxiter=maxiter,
|
715
|
+
fprime2=fprime2,
|
716
|
+
x1=x1,
|
717
|
+
rtol=rtol,
|
718
|
+
full_output=full_output,
|
719
|
+
disp=disp,
|
720
|
+
)
|
721
|
+
|
722
|
+
return breakeven_cost_usd_p_kw, {
|
723
|
+
k: loan.Outputs.export().get(k, None) for k in pysam_outputs
|
724
|
+
}
|
725
|
+
|
726
|
+
except Exception as e:
|
727
|
+
raise ValueError("Root finding failed.") from e
|
728
|
+
|
729
|
+
else:
|
730
|
+
raise ValueError("Invalid method passed to find_breakeven function")
|
731
|
+
|
732
|
+
|
733
|
+
def find_breakeven_fom(
|
734
|
+
row,
|
735
|
+
financial,
|
736
|
+
pysam_outputs,
|
737
|
+
pre_calc_bounds_and_tolerances=True,
|
738
|
+
**kwargs,
|
739
|
+
):
|
740
|
+
# calculate theoretical min/max NPV values
|
741
|
+
min_npv = calc_financial_performance_fom(1e9, row, financial)
|
742
|
+
|
743
|
+
if min_npv > 0.0:
|
744
|
+
# if "infinite" cost system yields positive NPV,
|
745
|
+
# then system economical at any price - return 1e9 as flag
|
746
|
+
out = 1e9
|
747
|
+
return out, {"msg": "Inf cost w/ positive NPV"}
|
748
|
+
|
749
|
+
max_npv = calc_financial_performance_fom(0.0, row, financial)
|
750
|
+
|
751
|
+
if max_npv < 0.0:
|
752
|
+
# if zero-cost system yields negative NPV,
|
753
|
+
# then no breakeven cost exists - return -1 as flag
|
754
|
+
out = -1.0
|
755
|
+
return out, {"msg": "Zero cost w/ negative NPV"}
|
756
|
+
|
757
|
+
# pre-calculate bounds for bisect and brentq methods
|
758
|
+
if pre_calc_bounds_and_tolerances:
|
759
|
+
pre_results = []
|
760
|
+
pre_capex_array = np.logspace(9, 0, num=500)
|
761
|
+
for capex_usd_p_kw in pre_capex_array:
|
762
|
+
pre_npv = calc_financial_performance_fom(capex_usd_p_kw, row, financial)
|
763
|
+
pre_results.append(pre_npv)
|
764
|
+
|
765
|
+
pre_results = np.asarray(pre_results)
|
766
|
+
|
767
|
+
# find index where NPV flips from negative to positive
|
768
|
+
ind = np.where(np.diff(np.sign(pre_results)))[0][0]
|
769
|
+
|
770
|
+
# get lower (price with negative NPV) and upper (price with positive NPV) bounds
|
771
|
+
a = pre_capex_array[ind]
|
772
|
+
b = pre_capex_array[ind + 1]
|
773
|
+
|
774
|
+
# depending on magnitude of capex values,
|
775
|
+
# tolerance FOR NEWTON METHOD ONLY can be too small
|
776
|
+
# anecdotally, ratio of tol:capex should be ~ 1e-6
|
777
|
+
# pre-calculate tolerance as a proportion of
|
778
|
+
# capex value pre-calculated directly above
|
779
|
+
tol = 1e-6 * ((a + b) / 2)
|
780
|
+
|
781
|
+
if kwargs["method"] == "grid_search":
|
782
|
+
if kwargs["capex_array"] is None:
|
783
|
+
capex_array = np.arange(5000.0, -500.0, -500.0)
|
784
|
+
else:
|
785
|
+
capex_array = kwargs["capex_array"]
|
786
|
+
|
787
|
+
try:
|
788
|
+
results = []
|
789
|
+
for capex_usd_p_kw in capex_array:
|
790
|
+
npv = calc_financial_performance_fom(capex_usd_p_kw, row, financial)
|
791
|
+
results.append([capex_usd_p_kw, npv])
|
792
|
+
|
793
|
+
out = pd.DataFrame(results, columns=["capex_usd_p_kw", "npv"])
|
794
|
+
|
795
|
+
return out, {"msg": "Grid search cannot return additional PySAM outputs"}
|
796
|
+
|
797
|
+
except Exception as e:
|
798
|
+
raise ValueError("Grid search failed.") from e
|
799
|
+
elif kwargs["method"] == "bisect":
|
800
|
+
try:
|
801
|
+
# required args for 'bisect
|
802
|
+
if not pre_calc_bounds_and_tolerances:
|
803
|
+
a = kwargs["a"]
|
804
|
+
b = kwargs["b"]
|
805
|
+
|
806
|
+
# optional args for 'bisect'
|
807
|
+
xtol = kwargs["xtol"] if "xtol" in kwargs.keys() else 2e-12
|
808
|
+
rtol = kwargs["rtol"] if "rtol" in kwargs.keys() else 4 * np.finfo(float).eps
|
809
|
+
maxiter = kwargs["maxiter"] if "maxiter" in kwargs.keys() else 100
|
810
|
+
full_output = kwargs["full_output"] if "full_output" in kwargs.keys() else False
|
811
|
+
disp = kwargs["disp"] if "disp" in kwargs.keys() else True
|
812
|
+
except Exception as e:
|
813
|
+
raise ValueError("Make sure method-specific inputs are specified correctly.") from e
|
814
|
+
|
815
|
+
try:
|
816
|
+
breakeven_cost_usd_p_kw, r = optimize.bisect(
|
817
|
+
calc_financial_performance_fom,
|
818
|
+
args=(row, financial),
|
819
|
+
a=a,
|
820
|
+
b=b,
|
821
|
+
xtol=xtol,
|
822
|
+
rtol=rtol,
|
823
|
+
maxiter=maxiter,
|
824
|
+
full_output=full_output,
|
825
|
+
disp=disp,
|
826
|
+
)
|
827
|
+
|
828
|
+
return breakeven_cost_usd_p_kw, {
|
829
|
+
k: financial.Outputs.export().get(k, None) for k in pysam_outputs
|
830
|
+
}
|
831
|
+
|
832
|
+
except Exception as e:
|
833
|
+
raise ValueError("Root finding failed.") from e
|
834
|
+
elif kwargs["method"] == "brentq":
|
835
|
+
try:
|
836
|
+
# required args for 'brentq'
|
837
|
+
if not pre_calc_bounds_and_tolerances:
|
838
|
+
a = kwargs["a"]
|
839
|
+
b = kwargs["b"]
|
840
|
+
|
841
|
+
# optional args for 'brentq'
|
842
|
+
xtol = kwargs["xtol"] if "xtol" in kwargs.keys() else 2e-12
|
843
|
+
rtol = kwargs["rtol"] if "rtol" in kwargs.keys() else 4 * np.finfo(float).eps
|
844
|
+
maxiter = kwargs["maxiter"] if "maxiter" in kwargs.keys() else 100
|
845
|
+
full_output = kwargs["full_output"] if "full_output" in kwargs.keys() else False
|
846
|
+
disp = kwargs["disp"] if "disp" in kwargs.keys() else True
|
847
|
+
except Exception as e:
|
848
|
+
raise ValueError("Make sure method-specific inputs are specified correctly.") from e
|
849
|
+
|
850
|
+
try:
|
851
|
+
breakeven_cost_usd_p_kw, r = optimize.brentq(
|
852
|
+
calc_financial_performance_fom,
|
853
|
+
args=(row, financial),
|
854
|
+
a=a,
|
855
|
+
b=b,
|
856
|
+
xtol=xtol,
|
857
|
+
rtol=rtol,
|
858
|
+
maxiter=maxiter,
|
859
|
+
full_output=full_output,
|
860
|
+
disp=disp,
|
861
|
+
)
|
862
|
+
|
863
|
+
return breakeven_cost_usd_p_kw, {
|
864
|
+
k: financial.Outputs.export().get(k, None) for k in pysam_outputs
|
865
|
+
}
|
866
|
+
|
867
|
+
except Exception as e:
|
868
|
+
raise ValueError("Root finding failed.") from e
|
869
|
+
elif kwargs["method"] == "newton":
|
870
|
+
try:
|
871
|
+
# required args for 'newton'
|
872
|
+
x0 = kwargs["x0"]
|
873
|
+
|
874
|
+
if not pre_calc_bounds_and_tolerances:
|
875
|
+
tol = kwargs["tol"] if "tol" in kwargs.keys() else 1.48e-08
|
876
|
+
|
877
|
+
# optional args for 'newton'
|
878
|
+
fprime = kwargs["fprime"] if "fprime" in kwargs.keys() else None
|
879
|
+
maxiter = kwargs["maxiter"] if "maxiter" in kwargs.keys() else 50
|
880
|
+
fprime2 = kwargs["fprime2"] if "fprime2" in kwargs.keys() else None
|
881
|
+
x1 = kwargs["x1"] if "x1" in kwargs.keys() else None
|
882
|
+
rtol = kwargs["rtol"] if "rtol" in kwargs.keys() else 0.0
|
883
|
+
full_output = kwargs["full_output"] if "full_output" in kwargs.keys() else False
|
884
|
+
disp = kwargs["disp"] if "disp" in kwargs.keys() else True
|
885
|
+
except Exception as e:
|
886
|
+
raise ValueError("Make sure method-specific inputs are specified correctly") from e
|
887
|
+
|
888
|
+
try:
|
889
|
+
breakeven_cost_usd_p_kw, r = optimize.newton(
|
890
|
+
calc_financial_performance_fom,
|
891
|
+
args=(row, financial),
|
892
|
+
x0=x0,
|
893
|
+
fprime=fprime,
|
894
|
+
tol=tol,
|
895
|
+
maxiter=maxiter,
|
896
|
+
fprime2=fprime2,
|
897
|
+
x1=x1,
|
898
|
+
rtol=rtol,
|
899
|
+
full_output=full_output,
|
900
|
+
disp=disp,
|
901
|
+
)
|
902
|
+
|
903
|
+
return breakeven_cost_usd_p_kw, {
|
904
|
+
k: financial.Outputs.export().get(k, None) for k in pysam_outputs
|
905
|
+
}
|
906
|
+
|
907
|
+
except Exception as e:
|
908
|
+
raise ValueError("Root finding failed") from e
|
909
|
+
else:
|
910
|
+
raise ValueError("Invalid method passed to find_breakeven function")
|
911
|
+
|
912
|
+
|
913
|
+
def process_btm(
|
914
|
+
row,
|
915
|
+
tech,
|
916
|
+
generation_hourly,
|
917
|
+
consumption_hourly,
|
918
|
+
pysam_outputs,
|
919
|
+
en_batt=False,
|
920
|
+
batt_dispatch=None,
|
921
|
+
):
|
922
|
+
"""Behind-the-meter ...
|
923
|
+
|
924
|
+
This function processes a BTM agent by:
|
925
|
+
1)
|
926
|
+
|
927
|
+
Parameters
|
928
|
+
----------
|
929
|
+
**row** : 'DataFrame row'
|
930
|
+
The row of the dataframe on which the function is performed
|
931
|
+
**exported_hourly** : ''
|
932
|
+
8760 of generation
|
933
|
+
**consumption_hourly** : ''
|
934
|
+
8760 of consumption
|
935
|
+
**tariff_dict** : 'dict'
|
936
|
+
Dictionary containing tariff parameters
|
937
|
+
**btm_nem** : 'bool'
|
938
|
+
Enable NEM for BTM parcels
|
939
|
+
**en_batt** : 'bool'
|
940
|
+
Enable battery modeling
|
941
|
+
**batt_dispatch** : 'string'
|
942
|
+
Specify battery dispatch strategy type
|
943
|
+
|
944
|
+
Returns:
|
945
|
+
-------
|
946
|
+
|
947
|
+
"""
|
948
|
+
# extract agent load and generation profiles
|
949
|
+
generation_hourly = np.array(generation_hourly)
|
950
|
+
consumption_hourly = np.array(consumption_hourly, dtype=np.float32)
|
951
|
+
|
952
|
+
# specify tech-agnostic system size column
|
953
|
+
row["system_size_kw"] = row[f"{tech}_size_kw_btm"]
|
954
|
+
|
955
|
+
# instantiate PySAM battery model based on agent sector
|
956
|
+
if row.loc["sector_abbr"] == "res":
|
957
|
+
batt = battery.default("GenericBatteryResidential")
|
958
|
+
else:
|
959
|
+
batt = battery.default("GenericBatteryCommercial")
|
960
|
+
|
961
|
+
# instantiate PySAM utilityrate5 model based on agent sector
|
962
|
+
if row.loc["sector_abbr"] == "res":
|
963
|
+
utilityrate = ur5.default("GenericBatteryResidential")
|
964
|
+
else:
|
965
|
+
utilityrate = ur5.default("GenericBatteryCommercial")
|
966
|
+
|
967
|
+
######################################
|
968
|
+
###--------- UTILITYRATE5 ---------###
|
969
|
+
###--- SYSTEM LIFETIME SETTINGS ---###
|
970
|
+
######################################
|
971
|
+
|
972
|
+
# Inflation rate [%]
|
973
|
+
utilityrate.Lifetime.inflation_rate = row.loc["inflation_rate"] * 100
|
974
|
+
|
975
|
+
# Number of years in analysis [years]
|
976
|
+
utilityrate.Lifetime.analysis_period = row.loc["economic_lifetime_yrs"]
|
977
|
+
|
978
|
+
# Lifetime hourly system outputs [0/1];
|
979
|
+
# Options: 0=hourly first year,1=hourly lifetime
|
980
|
+
utilityrate.Lifetime.system_use_lifetime_output = 0
|
981
|
+
|
982
|
+
######################################
|
983
|
+
###--------- UTILITYRATE5 ---------###
|
984
|
+
###---- DEGRADATION/ESCALATION ----###
|
985
|
+
######################################
|
986
|
+
|
987
|
+
# Annual energy degradation [%]
|
988
|
+
utilityrate.SystemOutput.degradation = [row.loc["system_degradation"] * 100]
|
989
|
+
# Annual electricity rate escalation [%/year]
|
990
|
+
utilityrate.ElectricityRates.rate_escalation = [row.loc["elec_price_escalator"] * 100]
|
991
|
+
|
992
|
+
######################################
|
993
|
+
###--------- UTILITYRATE5 ---------###
|
994
|
+
###---- NET METERING SETTINGS -----###
|
995
|
+
######################################
|
996
|
+
|
997
|
+
# dictionary to map dGen compensation styles to PySAM options
|
998
|
+
nem_options = {"net metering": 0, "net billing": 2, "buy all sell all": 4, "none": 2}
|
999
|
+
|
1000
|
+
# metering options
|
1001
|
+
# 0 = net energy metering
|
1002
|
+
# 1 = net energy metering with $ credits
|
1003
|
+
# 2 = net billing
|
1004
|
+
# 3 = net billing with carryover to next month
|
1005
|
+
# 4 = buy all - sell all
|
1006
|
+
c_style = row.loc["compensation_style"]
|
1007
|
+
utilityrate.ElectricityRates.ur_metering_option = nem_options[c_style]
|
1008
|
+
|
1009
|
+
# year end sell rate [$/kWh]
|
1010
|
+
ws_price = row.loc["wholesale_elec_price_dollars_per_kwh"]
|
1011
|
+
mult = row.loc["elec_price_multiplier"]
|
1012
|
+
utilityrate.ElectricityRates.ur_nm_yearend_sell_rate = ws_price * mult
|
1013
|
+
|
1014
|
+
if c_style == "none":
|
1015
|
+
net_billing_sell_rate = 0.0
|
1016
|
+
else:
|
1017
|
+
net_billing_sell_rate = ws_price * mult
|
1018
|
+
|
1019
|
+
######################################
|
1020
|
+
###--------- UTILITYRATE5 ---------###
|
1021
|
+
###-------- BUY/SELL RATES --------###
|
1022
|
+
######################################
|
1023
|
+
|
1024
|
+
# Enable time step sell rates [0/1]
|
1025
|
+
utilityrate.ElectricityRates.ur_en_ts_sell_rate = 0
|
1026
|
+
|
1027
|
+
# Time step sell rates [0/1]
|
1028
|
+
utilityrate.ElectricityRates.ur_ts_sell_rate = [0.0]
|
1029
|
+
|
1030
|
+
# Set sell rate equal to buy rate [0/1]
|
1031
|
+
utilityrate.ElectricityRates.ur_sell_eq_buy = 0
|
1032
|
+
|
1033
|
+
######################################
|
1034
|
+
###--------- UTILITYRATE5 ---------###
|
1035
|
+
###-------- MISC. SETTINGS --------###
|
1036
|
+
######################################
|
1037
|
+
|
1038
|
+
# Use single monthly peak for TOU demand charge;
|
1039
|
+
# options:
|
1040
|
+
# 0 = use TOU peak
|
1041
|
+
# 1 = use flat peak
|
1042
|
+
utilityrate.ElectricityRates.TOU_demand_single_peak = 0 # ?
|
1043
|
+
|
1044
|
+
# Optionally enable/disable electricity_rate [years]
|
1045
|
+
utilityrate.ElectricityRates.en_electricity_rates = 1
|
1046
|
+
|
1047
|
+
######################################
|
1048
|
+
###--------- UTILITYRATE5 ---------###
|
1049
|
+
###----- TARIFF RESTRUCTURING -----###
|
1050
|
+
######################################
|
1051
|
+
utilityrate = process_tariff(utilityrate, row, net_billing_sell_rate)
|
1052
|
+
|
1053
|
+
######################################
|
1054
|
+
###----------- CASHLOAN -----------###
|
1055
|
+
###----- FINANCIAL PARAMETERS -----###
|
1056
|
+
######################################
|
1057
|
+
|
1058
|
+
# Initiate cashloan model and set market-specific variables
|
1059
|
+
# Assume res agents do not evaluate depreciation at all
|
1060
|
+
# Assume non-res agents only evaluate federal depreciation (not state)
|
1061
|
+
if row.loc["sector_abbr"] == "res":
|
1062
|
+
loan = cashloan.default("GenericBatteryResidential")
|
1063
|
+
loan.FinancialParameters.market = 0
|
1064
|
+
else:
|
1065
|
+
loan = cashloan.default("GenericBatteryCommercial")
|
1066
|
+
loan.FinancialParameters.market = 1
|
1067
|
+
|
1068
|
+
loan.FinancialParameters.analysis_period = row.loc["economic_lifetime_yrs"]
|
1069
|
+
loan.FinancialParameters.debt_fraction = 100 - (row.loc["down_payment_fraction"] * 100)
|
1070
|
+
loan.FinancialParameters.federal_tax_rate = [(row.loc["tax_rate"] * 100) * 0.7] # SAM default
|
1071
|
+
loan.FinancialParameters.inflation_rate = row.loc["inflation_rate"] * 100
|
1072
|
+
loan.FinancialParameters.insurance_rate = 0
|
1073
|
+
loan.FinancialParameters.loan_rate = row.loc["loan_interest_rate"] * 100
|
1074
|
+
loan.FinancialParameters.loan_term = row.loc["loan_term_yrs"]
|
1075
|
+
loan.FinancialParameters.mortgage = 0 # default value - standard loan (no mortgage)
|
1076
|
+
loan.FinancialParameters.prop_tax_assessed_decline = 5 # PySAM default
|
1077
|
+
loan.FinancialParameters.prop_tax_cost_assessed_percent = 95 # PySAM default
|
1078
|
+
loan.FinancialParameters.property_tax_rate = 0 # PySAM default
|
1079
|
+
loan.FinancialParameters.real_discount_rate = row.loc["real_discount_rate"] * 100
|
1080
|
+
loan.FinancialParameters.salvage_percentage = 0
|
1081
|
+
loan.FinancialParameters.state_tax_rate = [(row.loc["tax_rate"] * 100) * 0.3] # SAM default
|
1082
|
+
loan.FinancialParameters.system_heat_rate = 0
|
1083
|
+
|
1084
|
+
######################################
|
1085
|
+
###----------- CASHLOAN -----------###
|
1086
|
+
###--------- SYSTEM COSTS ---------###
|
1087
|
+
######################################
|
1088
|
+
|
1089
|
+
# System costs that are input to loan.SystemCosts will depend on system configuration
|
1090
|
+
# (PV, batt, PV+batt) and are therefore specified in calc_system_performance()
|
1091
|
+
|
1092
|
+
system_costs = {}
|
1093
|
+
system_costs["system_om_per_kw"] = row["system_om_per_kw"]
|
1094
|
+
system_costs["system_variable_om_per_kw"] = row["system_variable_om_per_kw"]
|
1095
|
+
system_costs["cap_cost_multiplier"] = row["cap_cost_multiplier"]
|
1096
|
+
system_costs["batt_capex_per_kw"] = row["batt_capex_per_kw"]
|
1097
|
+
system_costs["batt_capex_per_kwh"] = row["batt_capex_per_kwh"]
|
1098
|
+
system_costs["batt_om_per_kw"] = row["batt_om_per_kw"]
|
1099
|
+
system_costs["batt_om_per_kwh"] = row["batt_om_per_kwh"]
|
1100
|
+
system_costs["linear_constant"] = row["linear_constant"]
|
1101
|
+
|
1102
|
+
# costs for PV+batt configuration are distinct from standalone techs
|
1103
|
+
# TODO: _combined costs are only valid for PV+batt -- process these differently for wind?
|
1104
|
+
|
1105
|
+
######################################
|
1106
|
+
###----------- CASHLOAN -----------###
|
1107
|
+
###---- DEPRECIATION PARAMETERS ---###
|
1108
|
+
######################################
|
1109
|
+
|
1110
|
+
if row.loc["sector_abbr"] == "res":
|
1111
|
+
loan.Depreciation.depr_fed_type = 0
|
1112
|
+
loan.Depreciation.depr_sta_type = 0
|
1113
|
+
else:
|
1114
|
+
loan.Depreciation.depr_fed_type = 1
|
1115
|
+
loan.Depreciation.depr_sta_type = 0
|
1116
|
+
|
1117
|
+
######################################
|
1118
|
+
###----------- CASHLOAN -----------###
|
1119
|
+
###----- TAX CREDIT INCENTIVES ----###
|
1120
|
+
######################################
|
1121
|
+
|
1122
|
+
itc_fed_pct = row.loc["itc_fraction_of_capex"]
|
1123
|
+
itc_fed_pct = itc_fed_pct * 100
|
1124
|
+
if itc_fed_pct != 0:
|
1125
|
+
loan.TaxCreditIncentives.itc_fed_percent = itc_fed_pct # [itc_fed_pct]
|
1126
|
+
|
1127
|
+
######################################
|
1128
|
+
###----------- CASHLOAN -----------###
|
1129
|
+
###-------- BATTERY SYSTEM --------###
|
1130
|
+
######################################
|
1131
|
+
|
1132
|
+
loan.BatterySystem.batt_replacement_option = 2 # user schedule
|
1133
|
+
|
1134
|
+
batt_replacement_schedule = [0 for i in range(0, row.loc["batt_lifetime_yrs"] - 1)] + [100]
|
1135
|
+
loan.BatterySystem.batt_replacement_schedule_percent = batt_replacement_schedule
|
1136
|
+
|
1137
|
+
######################################
|
1138
|
+
###----------- CASHLOAN -----------###
|
1139
|
+
###-------- SYSTEM OUTPUT ---------###
|
1140
|
+
######################################
|
1141
|
+
|
1142
|
+
loan.SystemOutput.degradation = [row.loc["system_degradation"] * 100]
|
1143
|
+
|
1144
|
+
######################################
|
1145
|
+
###----------- CASHLOAN -----------###
|
1146
|
+
###----------- LIFETIME -----------###
|
1147
|
+
######################################
|
1148
|
+
|
1149
|
+
loan.Lifetime.system_use_lifetime_output = 0
|
1150
|
+
|
1151
|
+
######################################
|
1152
|
+
###--- INITIALIZE PYSAM MODULES ---###
|
1153
|
+
######################################
|
1154
|
+
|
1155
|
+
inv_eff = 0.96 # default SAM inverter efficiency for PV
|
1156
|
+
gen = [i * inv_eff for i in generation_hourly]
|
1157
|
+
|
1158
|
+
# set up battery, with system generation conditional
|
1159
|
+
# on the battery generation being included
|
1160
|
+
if en_batt:
|
1161
|
+
batt.BatterySystem.en_batt = 1
|
1162
|
+
batt.BatterySystem.batt_ac_or_dc = 1 # default value
|
1163
|
+
batt.BatteryCell.batt_chem = 1 # default value is 1: li ion for residential
|
1164
|
+
batt.BatterySystem.batt_meter_position = 0
|
1165
|
+
|
1166
|
+
# need to consider lifetime since pysam needs
|
1167
|
+
# profiles for all years if considering replacement
|
1168
|
+
batt.Lifetime.system_use_lifetime_output = 0
|
1169
|
+
batt.BatterySystem.batt_replacement_option = 0
|
1170
|
+
|
1171
|
+
batt.Inverter.inverter_model = 4 # default value
|
1172
|
+
batt.Load.load = consumption_hourly
|
1173
|
+
|
1174
|
+
# set different ratios for residential and comm/industrial systems
|
1175
|
+
sec = row["sector_abbr"]
|
1176
|
+
if sec == "res":
|
1177
|
+
# pv to Battery ratio (kW) - From Ashreeta, 02/08/2020 (1.31372) updated 9/24
|
1178
|
+
pv_to_batt_ratio = 1.21
|
1179
|
+
else:
|
1180
|
+
# updated 9/24 to reflect values from cost report, Denholm et.al.
|
1181
|
+
pv_to_batt_ratio = 1.67
|
1182
|
+
|
1183
|
+
batt_capacity_to_power_ratio = 2 # hours of operation
|
1184
|
+
|
1185
|
+
# default SAM value for residential systems is 10
|
1186
|
+
desired_size = row["system_size_kw"] / pv_to_batt_ratio
|
1187
|
+
desired_power = desired_size / batt_capacity_to_power_ratio
|
1188
|
+
|
1189
|
+
battery_tools.battery_model_sizing(batt, desired_power, desired_size, 500)
|
1190
|
+
|
1191
|
+
# copy over gen and load
|
1192
|
+
batt.Load.load = consumption_hourly # kw
|
1193
|
+
batt.SystemOutput.gen = gen
|
1194
|
+
|
1195
|
+
# dispatch options in detailed battery:
|
1196
|
+
if batt_dispatch == "peak_shaving":
|
1197
|
+
batt.BatteryDispatch.batt_dispatch_choice = 0
|
1198
|
+
else:
|
1199
|
+
batt.BatteryDispatch.batt_dispatch_choice = 5
|
1200
|
+
|
1201
|
+
batt.BatteryDispatch.batt_dispatch_auto_can_charge = 1
|
1202
|
+
batt.BatteryDispatch.batt_dispatch_auto_can_clipcharge = 1
|
1203
|
+
batt.BatteryDispatch.batt_dispatch_auto_can_gridcharge = 1
|
1204
|
+
cycle_cost_list = [0.1]
|
1205
|
+
batt.BatterySystem.batt_cycle_cost = cycle_cost_list[0]
|
1206
|
+
batt.BatterySystem.batt_cycle_cost_choice = 0
|
1207
|
+
# batt.BatteryDispatch.batt_pv_ac_forecast = batt_model.Battery.ac
|
1208
|
+
batt.execute(0)
|
1209
|
+
|
1210
|
+
loan.BatterySystem.en_batt = 1
|
1211
|
+
loan.BatterySystem.batt_computed_bank_capacity = batt.Outputs.batt_bank_installed_capacity
|
1212
|
+
loan.BatterySystem.batt_bank_replacement = batt.Outputs.batt_bank_replacement
|
1213
|
+
|
1214
|
+
# specify number of O&M types (1 = PV+batt)
|
1215
|
+
loan.SystemCosts.add_om_num_types = 1
|
1216
|
+
|
1217
|
+
loan.BatterySystem.battery_per_kWh = system_costs["batt_capex_per_kwh"]
|
1218
|
+
|
1219
|
+
loan.SystemCosts.om_capacity = [
|
1220
|
+
system_costs["system_om_per_kw"] + system_costs["system_variable_om_per_kw"]
|
1221
|
+
]
|
1222
|
+
loan.SystemCosts.om_capacity1 = [system_costs["batt_om_per_kw"]]
|
1223
|
+
loan.SystemCosts.om_production1 = [system_costs["batt_om_per_kwh"] * 1000.0]
|
1224
|
+
loan.SystemCosts.om_batt_replacement_cost = [0.0]
|
1225
|
+
|
1226
|
+
# specify linear constant adder for standalone battery system
|
1227
|
+
row["linear_constant"]
|
1228
|
+
|
1229
|
+
# Battery capacity for System Costs values [kW]
|
1230
|
+
loan.SystemCosts.om_capacity1_nameplate = batt.BatterySystem.batt_power_charge_max_kwdc
|
1231
|
+
# Battery production for System Costs values [kWh]
|
1232
|
+
loan.SystemCosts.om_production1_values = [batt.Outputs.batt_bank_installed_capacity]
|
1233
|
+
|
1234
|
+
batt_costs = (
|
1235
|
+
system_costs["batt_capex_per_kw"] * batt.BatterySystem.batt_power_charge_max_kwdc
|
1236
|
+
) + (system_costs["batt_capex_per_kwh"] * batt.Outputs.batt_bank_installed_capacity)
|
1237
|
+
|
1238
|
+
value_of_resiliency = 0.0 # TODO: add value of resiliency?
|
1239
|
+
utilityrate.SystemOutput.gen = batt.SystemOutput.gen
|
1240
|
+
|
1241
|
+
else:
|
1242
|
+
batt.BatterySystem.en_batt = 0
|
1243
|
+
loan.BatterySystem.en_batt = 0
|
1244
|
+
|
1245
|
+
loan.Battery.batt_annual_charge_from_system = [0.0]
|
1246
|
+
loan.Battery.batt_annual_discharge_energy = [0.0]
|
1247
|
+
loan.Battery.batt_capacity_percent = [0.0]
|
1248
|
+
loan.Battery.battery_total_cost_lcos = 0.0
|
1249
|
+
loan.Battery.grid_to_batt = [0.0]
|
1250
|
+
loan.Battery.monthly_batt_to_grid = [0.0]
|
1251
|
+
loan.Battery.monthly_grid_to_batt = [0.0]
|
1252
|
+
loan.Battery.monthly_grid_to_load = [0.0]
|
1253
|
+
loan.Battery.monthly_system_to_grid = [0.0]
|
1254
|
+
|
1255
|
+
# for PySAM 4.0
|
1256
|
+
# loan.LCOS.batt_annual_charge_energy = [0.]
|
1257
|
+
# loan.LCOS.batt_annual_charge_from_system = [0.]
|
1258
|
+
# loan.LCOS.batt_annual_discharge_energy = [0.]
|
1259
|
+
# loan.LCOS.batt_capacity_percent = [0.]
|
1260
|
+
# loan.LCOS.battery_total_cost_lcos = 0.
|
1261
|
+
# loan.LCOS.charge_w_sys_ec_ym = [[0.]]
|
1262
|
+
# loan.LCOS.grid_to_batt = [0.]
|
1263
|
+
# loan.LCOS.monthly_batt_to_grid = [0.]
|
1264
|
+
# loan.LCOS.monthly_grid_to_batt = [0.]
|
1265
|
+
# loan.LCOS.monthly_grid_to_load = [0.]
|
1266
|
+
# loan.LCOS.monthly_system_to_grid = [0.]
|
1267
|
+
# loan.LCOS.true_up_credits_ym = [[0.]]
|
1268
|
+
# loan.LCOS.year1_monthly_ec_charge_gross_with_system = [0.]
|
1269
|
+
# loan.LCOS.year1_monthly_ec_charge_with_system = [0.]
|
1270
|
+
# loan.LCOS.year1_monthly_electricity_to_grid = [0.]
|
1271
|
+
|
1272
|
+
# specify number of O&M types (0 = PV only)
|
1273
|
+
loan.SystemCosts.add_om_num_types = 0
|
1274
|
+
# since battery system size is zero, specify standalone PV O&M costs
|
1275
|
+
loan.SystemCosts.om_capacity = [
|
1276
|
+
system_costs["system_om_per_kw"] + system_costs["system_variable_om_per_kw"]
|
1277
|
+
]
|
1278
|
+
loan.SystemCosts.om_batt_replacement_cost = [0.0]
|
1279
|
+
|
1280
|
+
batt_costs = 0.0
|
1281
|
+
|
1282
|
+
# linear constant for standalone PV system is 0.
|
1283
|
+
|
1284
|
+
value_of_resiliency = 0.0
|
1285
|
+
utilityrate.SystemOutput.gen = gen
|
1286
|
+
|
1287
|
+
# Execute utility rate module
|
1288
|
+
utilityrate.Load.load = consumption_hourly
|
1289
|
+
utilityrate.execute(1)
|
1290
|
+
|
1291
|
+
# Process payment incentives
|
1292
|
+
# TODO: apply incentives?
|
1293
|
+
# loan = process_incentives(
|
1294
|
+
# loan, row['system_size_kw'],
|
1295
|
+
# batt.BatterySystem.batt_power_discharge_max_kwdc,
|
1296
|
+
# batt.Outputs.batt_bank_installed_capacity,
|
1297
|
+
# generation_hourly,
|
1298
|
+
# row
|
1299
|
+
# )
|
1300
|
+
|
1301
|
+
# specify final Cashloan parameters
|
1302
|
+
loan.FinancialParameters.system_capacity = row["system_size_kw"]
|
1303
|
+
|
1304
|
+
# add value_of_resiliency -- should only apply from year 1 onwards, not to year 0
|
1305
|
+
aev = utilityrate.Outputs.annual_energy_value
|
1306
|
+
annual_energy_value = [aev[0]] + [x + value_of_resiliency for i, x in enumerate(aev) if i != 0]
|
1307
|
+
|
1308
|
+
loan.SystemOutput.annual_energy_value = annual_energy_value
|
1309
|
+
loan.SystemOutput.gen = utilityrate.SystemOutput.gen
|
1310
|
+
loan.ThirdPartyOwnership.elec_cost_with_system = utilityrate.Outputs.elec_cost_with_system
|
1311
|
+
loan.ThirdPartyOwnership.elec_cost_without_system = utilityrate.Outputs.elec_cost_without_system
|
1312
|
+
|
1313
|
+
_ = calc_financial_performance(row["system_capex_per_kw"], row, loan, batt_costs)
|
1314
|
+
|
1315
|
+
row["additional_pysam_outputs"] = {k: loan.Outputs.export().get(k, None) for k in pysam_outputs}
|
1316
|
+
|
1317
|
+
# run root finding algorithm to find breakeven cost based on calculated NPV
|
1318
|
+
out, _ = find_breakeven(
|
1319
|
+
row=row,
|
1320
|
+
loan=loan,
|
1321
|
+
pysam_outputs=pysam_outputs,
|
1322
|
+
batt_costs=batt_costs,
|
1323
|
+
pre_calc_bounds_and_tolerances=False,
|
1324
|
+
**{"method": "newton", "x0": 10000.0, "full_output": True},
|
1325
|
+
)
|
1326
|
+
|
1327
|
+
row["breakeven_cost_usd_p_kw"] = out
|
1328
|
+
|
1329
|
+
return row
|
1330
|
+
|
1331
|
+
|
1332
|
+
def process_fom(
|
1333
|
+
row,
|
1334
|
+
tech,
|
1335
|
+
generation_hourly,
|
1336
|
+
market_profile,
|
1337
|
+
pysam_outputs,
|
1338
|
+
en_batt=False,
|
1339
|
+
batt_dispatch=None,
|
1340
|
+
):
|
1341
|
+
"""Front-of-the-meter ...
|
1342
|
+
|
1343
|
+
This function processes a FOM agent by:
|
1344
|
+
1)
|
1345
|
+
|
1346
|
+
Parameters
|
1347
|
+
----------
|
1348
|
+
**row** : 'DataFrame row'
|
1349
|
+
The row of the dataframe on which the function is performed
|
1350
|
+
**exported_hourly** : ''
|
1351
|
+
8760 of generation
|
1352
|
+
**cambium_grid_value** : ''
|
1353
|
+
8760 of Cambium values
|
1354
|
+
|
1355
|
+
Returns:
|
1356
|
+
-------
|
1357
|
+
|
1358
|
+
"""
|
1359
|
+
# extract generation profile
|
1360
|
+
generation_hourly = np.array(generation_hourly)
|
1361
|
+
|
1362
|
+
# specify tech-agnostic system size column
|
1363
|
+
row["system_size_kw"] = row[f"{tech}_size_kw_fom"]
|
1364
|
+
|
1365
|
+
inv_eff = 1.0 # required inverter efficiency for FOM systems
|
1366
|
+
gen = [i * inv_eff for i in generation_hourly]
|
1367
|
+
|
1368
|
+
# set up battery, with system generation conditional
|
1369
|
+
# on the battery generation being included
|
1370
|
+
if en_batt:
|
1371
|
+
# TODO: implement FOM battery
|
1372
|
+
pass
|
1373
|
+
else:
|
1374
|
+
# initialize PySAM model
|
1375
|
+
if tech == "solar":
|
1376
|
+
financial = mp.default("PVWattsMerchantPlant")
|
1377
|
+
elif tech == "wind":
|
1378
|
+
financial = mp.default("WindPowerMerchantPlant")
|
1379
|
+
else:
|
1380
|
+
msg = "Please write a wrapper to account for the new technology type"
|
1381
|
+
raise NotImplementedError(f"{msg} {tech}")
|
1382
|
+
|
1383
|
+
ptc_fed_amt = row[f"ptc_fed_amt_{tech}"]
|
1384
|
+
itc_fed_pct = row["itc_fed_pct"]
|
1385
|
+
deg = row["deg"]
|
1386
|
+
system_capex_per_kw = row["system_capex_per_kw"]
|
1387
|
+
system_om_per_kw = row["system_om_per_kw"]
|
1388
|
+
|
1389
|
+
financial.Lifetime.system_use_lifetime_output = 0
|
1390
|
+
financial.FinancialParameters.analysis_period = row["analysis_period"]
|
1391
|
+
financial.FinancialParameters.debt_option = row["debt_option"]
|
1392
|
+
financial.FinancialParameters.debt_percent = row["debt_percent"]
|
1393
|
+
financial.FinancialParameters.inflation_rate = row["inflation_rate"]
|
1394
|
+
financial.FinancialParameters.dscr = row["dscr"]
|
1395
|
+
financial.FinancialParameters.real_discount_rate = row["real_discount_rate"]
|
1396
|
+
financial.FinancialParameters.term_int_rate = row["term_int_rate"]
|
1397
|
+
financial.FinancialParameters.term_tenor = row["term_tenor"]
|
1398
|
+
financial.FinancialParameters.insurance_rate = 0
|
1399
|
+
financial.FinancialParameters.federal_tax_rate = [21]
|
1400
|
+
financial.FinancialParameters.state_tax_rate = [7]
|
1401
|
+
financial.FinancialParameters.property_tax_rate = 0
|
1402
|
+
financial.FinancialParameters.prop_tax_cost_assessed_percent = 100
|
1403
|
+
financial.FinancialParameters.prop_tax_assessed_decline = 0
|
1404
|
+
|
1405
|
+
financial.FinancialParameters.cost_debt_closing = 0
|
1406
|
+
financial.FinancialParameters.cost_debt_fee = 1.5
|
1407
|
+
financial.FinancialParameters.cost_other_financing = 0
|
1408
|
+
financial.FinancialParameters.dscr_reserve_months = 0
|
1409
|
+
financial.FinancialParameters.equip1_reserve_cost = 0
|
1410
|
+
financial.FinancialParameters.equip1_reserve_freq = 0
|
1411
|
+
financial.FinancialParameters.equip2_reserve_cost = 0
|
1412
|
+
financial.FinancialParameters.equip2_reserve_freq = 0
|
1413
|
+
financial.FinancialParameters.equip3_reserve_cost = 0
|
1414
|
+
financial.FinancialParameters.equip3_reserve_freq = 0
|
1415
|
+
financial.FinancialParameters.months_receivables_reserve = 0
|
1416
|
+
financial.FinancialParameters.months_working_reserve = 0
|
1417
|
+
financial.FinancialParameters.reserves_interest = 0
|
1418
|
+
financial.FinancialParameters.salvage_percentage = 0
|
1419
|
+
|
1420
|
+
financial.TaxCreditIncentives.ptc_fed_amount = [ptc_fed_amt]
|
1421
|
+
financial.TaxCreditIncentives.ptc_fed_escal = 2.5
|
1422
|
+
financial.TaxCreditIncentives.ptc_fed_term = 10
|
1423
|
+
|
1424
|
+
itc_fed_pct = itc_fed_pct * 100
|
1425
|
+
if itc_fed_pct != 0:
|
1426
|
+
financial.TaxCreditIncentives.itc_fed_percent = itc_fed_pct # [itc_fed_pct]
|
1427
|
+
|
1428
|
+
financial.Depreciation.depr_custom_schedule = [0]
|
1429
|
+
|
1430
|
+
financial.SystemCosts.om_fixed = [0]
|
1431
|
+
financial.SystemCosts.om_fixed_escal = 0
|
1432
|
+
financial.SystemCosts.om_production = [0]
|
1433
|
+
financial.SystemCosts.om_production_escal = 0
|
1434
|
+
financial.SystemCosts.om_capacity_escal = 0
|
1435
|
+
financial.SystemCosts.om_fuel_cost = [0]
|
1436
|
+
financial.SystemCosts.om_fuel_cost_escal = 0
|
1437
|
+
financial.SystemCosts.om_replacement_cost_escal = 0
|
1438
|
+
|
1439
|
+
financial.SystemOutput.degradation = [deg * 100]
|
1440
|
+
financial.SystemOutput.system_capacity = row.loc["system_size_kw"]
|
1441
|
+
financial.SystemOutput.gen = gen
|
1442
|
+
financial.SystemOutput.system_pre_curtailment_kwac = gen
|
1443
|
+
financial.SystemOutput.annual_energy_pre_curtailment_ac = np.sum(gen[:8760])
|
1444
|
+
|
1445
|
+
financial.Revenue.mp_enable_energy_market_revenue = 1
|
1446
|
+
financial.Revenue.mp_energy_market_revenue = market_profile
|
1447
|
+
financial.Revenue.mp_enable_ancserv1 = 0
|
1448
|
+
financial.Revenue.mp_enable_ancserv2 = 0
|
1449
|
+
financial.Revenue.mp_enable_ancserv3 = 0
|
1450
|
+
financial.Revenue.mp_enable_ancserv4 = 0
|
1451
|
+
financial.Revenue.mp_ancserv1_revenue = [(0, 0) for i in range(len(market_profile))]
|
1452
|
+
financial.Revenue.mp_ancserv2_revenue = [(0, 0) for i in range(len(market_profile))]
|
1453
|
+
financial.Revenue.mp_ancserv3_revenue = [(0, 0) for i in range(len(market_profile))]
|
1454
|
+
financial.Revenue.mp_ancserv4_revenue = [(0, 0) for i in range(len(market_profile))]
|
1455
|
+
|
1456
|
+
financial.CapacityPayments.cp_capacity_payment_type = 0
|
1457
|
+
financial.CapacityPayments.cp_capacity_payment_amount = [0]
|
1458
|
+
financial.CapacityPayments.cp_capacity_credit_percent = [0]
|
1459
|
+
financial.CapacityPayments.cp_capacity_payment_esc = 0
|
1460
|
+
financial.CapacityPayments.cp_system_nameplate = row.loc["system_size_kw"]
|
1461
|
+
|
1462
|
+
if en_batt:
|
1463
|
+
# TODO: add battery
|
1464
|
+
financial.CapacityPayments.cp_battery_nameplate = 0
|
1465
|
+
else:
|
1466
|
+
financial.CapacityPayments.cp_battery_nameplate = 0
|
1467
|
+
|
1468
|
+
financial.SystemCosts.om_capacity = [system_om_per_kw]
|
1469
|
+
|
1470
|
+
log.info(f"row {row.loc['gid']} calculating financial performance")
|
1471
|
+
_ = calc_financial_performance_fom(system_capex_per_kw, row, financial)
|
1472
|
+
row["additional_pysam_outputs"] = {
|
1473
|
+
k: financial.Outputs.export().get(k, None) for k in pysam_outputs
|
1474
|
+
}
|
1475
|
+
|
1476
|
+
# run root finding algorithm to find breakeven cost based on calculated NPV
|
1477
|
+
log.info(f"row {row.loc['gid']} breakeven")
|
1478
|
+
out, _ = find_breakeven_fom(
|
1479
|
+
row=row,
|
1480
|
+
financial=financial,
|
1481
|
+
pysam_outputs=pysam_outputs,
|
1482
|
+
pre_calc_bounds_and_tolerances=False,
|
1483
|
+
**{"method": "newton", "x0": 10000.0, "full_output": True},
|
1484
|
+
)
|
1485
|
+
|
1486
|
+
row["breakeven_cost_usd_p_kw"] = out
|
1487
|
+
|
1488
|
+
return row
|
1489
|
+
|
1490
|
+
|
1491
|
+
def worker(row: pd.Series, sector: str, config: Configuration):
|
1492
|
+
try:
|
1493
|
+
results = {}
|
1494
|
+
for tech in config.project.settings.TECHS:
|
1495
|
+
if tech == "wind":
|
1496
|
+
tech_config = row["turbine_class"]
|
1497
|
+
elif tech == "solar":
|
1498
|
+
# TODO: Not updated for the actual configuration, so solar is likely out of date
|
1499
|
+
azimuth = config.rev.settings.azimuth_direction_to_degree[row[f"azimuth_{sector}"]]
|
1500
|
+
tilt = row[f"tilt_{sector}"]
|
1501
|
+
tech_config = str(azimuth) + "_" + str(tilt)
|
1502
|
+
|
1503
|
+
if row[f"{tech}_size_kw_{sector}"] > 0:
|
1504
|
+
tech_config = row["turbine_class"]
|
1505
|
+
cf_hourly = find_cf_from_rev_wind(
|
1506
|
+
config.rev.DIR,
|
1507
|
+
config.project.settings.GENERATION_SCALE_OFFSET["wind"],
|
1508
|
+
tech_config,
|
1509
|
+
row[f"rev_index_{tech}"],
|
1510
|
+
)
|
1511
|
+
generation_hourly = cf_hourly * row[f"{tech}_size_kw_{sector}"]
|
1512
|
+
generation_hourly = generation_hourly.round(2)
|
1513
|
+
generation_hourly = np.array(generation_hourly).astype(np.float32).round(2)
|
1514
|
+
else:
|
1515
|
+
# if system size is zero, return dummy values
|
1516
|
+
results[f"{tech}_breakeven_cost_{sector}"] = -1
|
1517
|
+
results[f"{tech}_pysam_outputs_{sector}"] = {"msg": "System size is zero"}
|
1518
|
+
continue
|
1519
|
+
|
1520
|
+
if sector == "btm":
|
1521
|
+
row = process_btm(
|
1522
|
+
row=row,
|
1523
|
+
tech=tech,
|
1524
|
+
generation_hourly=generation_hourly,
|
1525
|
+
consumption_hourly=row["consumption_hourly"],
|
1526
|
+
pysam_outputs=config.pysam.outputs.btm,
|
1527
|
+
en_batt=False,
|
1528
|
+
batt_dispatch=None, # TODO: enable battery switch earlier
|
1529
|
+
)
|
1530
|
+
else:
|
1531
|
+
# fetch 8760 cambium values for FOM
|
1532
|
+
market_profile = fetch_cambium_values(
|
1533
|
+
row,
|
1534
|
+
generation_hourly,
|
1535
|
+
config.project.settings.CAMBIUM_DATA_DIR,
|
1536
|
+
config.CAMBIUM_VALUE,
|
1537
|
+
)
|
1538
|
+
|
1539
|
+
row = process_fom(
|
1540
|
+
row=row,
|
1541
|
+
tech=tech,
|
1542
|
+
generation_hourly=generation_hourly,
|
1543
|
+
market_profile=market_profile,
|
1544
|
+
pysam_outputs=config.pysam.outputs.fom,
|
1545
|
+
en_batt=False,
|
1546
|
+
batt_dispatch=None, # TODO: enable battery switch earlier
|
1547
|
+
)
|
1548
|
+
|
1549
|
+
# store results in dictionary
|
1550
|
+
results[f"{tech}_breakeven_cost_{sector}"] = row["breakeven_cost_usd_p_kw"]
|
1551
|
+
results[f"{tech}_pysam_outputs_{sector}"] = row["additional_pysam_outputs"]
|
1552
|
+
|
1553
|
+
return (row["gid"], results)
|
1554
|
+
|
1555
|
+
except Exception as e:
|
1556
|
+
log.exception(e)
|
1557
|
+
log.info("\n")
|
1558
|
+
log.info("Problem row:")
|
1559
|
+
log.info(row)
|
1560
|
+
log.info(results)
|
1561
|
+
|
1562
|
+
return (row["gid"], {})
|