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/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"], {})