dwind 0.3__py3-none-any.whl → 0.3.2__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 CHANGED
@@ -1,5 +1,10 @@
1
+ """Provides the core value calculation methods."""
2
+
3
+ from __future__ import annotations
4
+
1
5
  import time
2
6
  import logging
7
+ import pathlib
3
8
  import functools
4
9
  import concurrent.futures as cf
5
10
 
@@ -13,32 +18,37 @@ import PySAM.Utilityrate5 as ur5
13
18
  import PySAM.Merchantplant as mp
14
19
  from scipy import optimize
15
20
 
16
- from dwind import Configuration, loader, scenarios
21
+ from dwind import scenarios
22
+ from dwind.utils import loader
23
+ from dwind.config import Year, Sector, Scenario, Technology, Optimization, Configuration
17
24
 
18
25
 
19
26
  log = logging.getLogger("dwfs")
20
27
 
21
28
 
22
29
  class ValueFunctions:
30
+ """Primary model calculation engine responsible for the computation of individual agents."""
31
+
23
32
  def __init__(
24
33
  self,
25
- scenario: str,
26
- year: int,
34
+ scenario: str | Scenario,
35
+ year: int | Year,
27
36
  configuration: Configuration,
28
- return_format="totals",
37
+ return_format: str = "totals",
29
38
  ):
30
- """Primary model calculation engine responsible for the computation of individual agents.
39
+ """Creates an instance of the valuation class.
31
40
 
32
41
  Args:
33
42
  scenario (str): Only option is "baseline" currently.
34
43
  year (int): Analysis year.
35
44
  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
45
+ return_format (str, optional): One of "profiles", "total_profile", "totals", or "total"
46
+ to return individual value stream 8760s, a cumulative value stream 8760,
47
+ annual totals for each value stream, or a single cumulative total. Defaults to
48
+ "totals".
39
49
  """
40
- self.scenario = scenario
41
- self.year = year
50
+ self.scenario = Scenario(scenario)
51
+ self.year = Year(year)
42
52
  self.config = configuration
43
53
  self.return_format = return_format
44
54
 
@@ -50,19 +60,25 @@ class ValueFunctions:
50
60
  self.load()
51
61
 
52
62
  def load(self):
63
+ """Loads all the core data from CSV and SQL for configuring PySAM."""
53
64
  _load_csv = functools.partial(loader.load_df, year=self.year)
54
65
  _load_sql = functools.partial(
55
66
  loader.load_df,
56
67
  year=self.year,
57
- sql_constructur=self.confg.sql.ATLAS_PG_CON_STR,
68
+ sql_constructor=self.config.sql.ATLAS_PG_CON_STR,
58
69
  )
70
+ cost_dir = self.config.cost.DIR
59
71
 
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)
72
+ self.retail_rate_inputs = _load_csv(cost_dir / self.config.cost.RETAIL_RATE_INPUT_TABLE)
73
+ self.wholesale_rate_inputs = _load_csv(
74
+ cost_dir / self.config.cost.WHOLESALE_RATE_INPUT_TABLE
75
+ )
76
+ self.depreciation_schedule_inputs = _load_csv(
77
+ cost_dir / self.config.cost.DEPREC_INPUTS_TABLE
78
+ )
63
79
 
64
80
  if "wind" in self.config.project.settings.TECHS:
65
- self.wind_price_inputs = _load_sql(self.config.cost.WIND_PRICE_INPUT_TABLE)
81
+ self.wind_price_inputs = _load_csv(cost_dir / self.config.cost.WIND_PRICE_INPUT_TABLE)
66
82
  self.wind_tech_inputs = _load_sql(self.config.cost.WIND_TECH_INPUT_TABLE)
67
83
  self.wind_derate_inputs = _load_sql(self.config.cost.WIND_DERATE_INPUT_TABLE)
68
84
 
@@ -115,9 +131,18 @@ class ValueFunctions:
115
131
  costs["system_variable_om_per_kw"] = cost_inputs["system_variable_om_per_kw"][tech]
116
132
  return costs
117
133
 
118
- def _preprocess_btm(self, df, tech="wind"):
119
- # sec = row['sector_abbr']
120
- # county = int(row['county_id'])
134
+ def _preprocess_btm(self, df: pd.DataFrame, tech: str = "wind") -> pd.DataFrame:
135
+ """Join the core behind-the-meter wind and solar financial data to the agent dataframe
136
+ (:py:attr:`df`) based on turbine/solar sizing for each parcel.
137
+
138
+ Args:
139
+ df (pd.DataFrame): The agent data.
140
+ tech (str, optional): One of "solar" or "wind". Defaults to "wind".
141
+
142
+ Returns:
143
+ pd.DataFrame: Updated agent data with key financial data attached.
144
+ """
145
+ tech = Technology(tech)
121
146
  df["county_id_int"] = df.county_id.astype(int)
122
147
 
123
148
  # Get the electricity rates
@@ -138,8 +163,12 @@ class ValueFunctions:
138
163
  df = df.drop(columns="county_id_int")
139
164
 
140
165
  # 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)
166
+ if tech is Technology.SOLAR:
167
+ tech_join = "sector_abbr"
168
+ elif tech is Technology.WIND:
169
+ tech_join = "wind_turbine_kw_btm"
170
+
171
+ cost_df = self._process_btm_costs(self.COST_INPUTS["BTM"], tech.value)
143
172
  df = pd.merge(
144
173
  df,
145
174
  cost_df,
@@ -147,15 +176,15 @@ class ValueFunctions:
147
176
  left_on=tech_join,
148
177
  right_on=tech_join,
149
178
  )
150
- if tech == "solar":
179
+ if tech is Technology.SOLAR:
151
180
  df = pd.merge(
152
181
  df,
153
- self.PERFORMANCE_INPUTS[tech],
182
+ self.PERFORMANCE_INPUTS[tech.value],
154
183
  how="left",
155
184
  left_on=tech_join,
156
185
  right_on=tech_join,
157
186
  )
158
- else:
187
+ elif tech is Technology.WIND:
159
188
  df = pd.merge(
160
189
  df,
161
190
  self.wind_tech_inputs[["turbine_size_kw", "perf_improvement_factor"]],
@@ -177,10 +206,7 @@ class ValueFunctions:
177
206
  # field, and need to be removed, then rejoined with the appropriate column names
178
207
  financial = self.FINANCIAL_INPUTS["BTM"].copy()
179
208
  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"
209
+ incentives = self.FINANCIAL_INPUTS["BTM"].pop("itc_fraction_of_capex")
184
210
 
185
211
  deprec_sch = pd.DataFrame()
186
212
  deprec_sch["sector_abbr"] = financial["deprec_sch"].keys()
@@ -237,116 +263,62 @@ class ValueFunctions:
237
263
  return df
238
264
 
239
265
  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
- ]
266
+ """Join the core front-of-meter wind and solar financial data to the agent dataframe
267
+ (:py:attr:`df`) based on turbine/solar sizing for each parcel.
268
+
269
+ Args:
270
+ df (pd.DataFrame): The agent data.
271
+ tech (str, optional): One of "solar" or "wind". Defaults to "wind".
257
272
 
273
+ Returns:
274
+ pd.DataFrame: Updated agent data with key financial data attached.
275
+ """
276
+ tech = Technology(tech)
258
277
  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
278
+ df = df.assign(
279
+ yr=self.year.value,
280
+ cambium_scenario=self.CAMBIUM_SCENARIO,
281
+ analysis_period=self.FINANCIAL_INPUTS["FOM"]["system_lifetime"],
282
+ debt_option=self.FINANCIAL_INPUTS["FOM"]["debt_option"],
283
+ debt_percent=self.FINANCIAL_INPUTS["FOM"]["debt_percent"] * 100,
284
+ inflation_rate=self.FINANCIAL_INPUTS["FOM"]["inflation"] * 100,
285
+ dscr=self.FINANCIAL_INPUTS["FOM"]["dscr"],
286
+ real_discount_rate=self.FINANCIAL_INPUTS["FOM"]["discount_rate"] * 100,
287
+ term_int_rate=self.FINANCIAL_INPUTS["FOM"]["interest_rate"] * 100,
288
+ term_tenor=self.FINANCIAL_INPUTS["FOM"]["system_lifetime"],
289
+ itc_fed_pct=itc_fraction_of_capex if self.year != 2025 else 0.3,
290
+ deg=self.FINANCIAL_INPUTS["FOM"]["degradation"],
291
+ system_capex_per_kw=self.COST_INPUTS["FOM"]["system_capex_per_kw"][tech.value],
292
+ system_om_per_kw=self.COST_INPUTS["FOM"]["system_om_per_kw"][tech.value],
293
+ **{
294
+ f"ptc_fed_amt_{tech.value}": self.FINANCIAL_INPUTS["FOM"]["ptc_fed_dlrs_per_kwh"][
295
+ tech.value
296
+ ]
297
+ },
298
+ )
277
299
  # 2025 uses census-tract based applicable credit for the itc_fed_pct, so update accordingly
278
300
  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"
301
+ incentives = self.FINANCIAL_INPUTS["FOM"]["itc_fraction_of_capex"]
302
+
283
303
  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)
304
+ df.itc_fed_pct = df.applicable_credit.fillna(0.3)
286
305
 
287
306
  return df
288
307
 
289
- def run(self, agents: pd.DataFrame, sector: str):
290
- # self._connect_to_sql()
308
+ def run(self, agents: pd.DataFrame, sector: Sector):
309
+ """Run a multi-threaded PySAM analysis on each agent.
291
310
 
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
311
+ Args:
312
+ agents (pd.DataFrame): The fully prepared agent DataFrame.
313
+ sector (:py:class:`dwind.config.Sector`): One of "fom" (front-of-meter) or "btm"
314
+ (behind-the-meter).
344
315
 
345
- def run_multiprocessing(self, agents, sector):
346
- # uses cf.ProcessPoolExecutor rather than cf.ThreadPoolExecutor in run.py
347
- if sector == "btm":
316
+ Returns:
317
+ pd.DataFrame: An updated version of :py:attr:`agents` with PySAM results data.
318
+ """
319
+ if sector is Sector.BTM:
348
320
  agents = self._preprocess_btm(agents)
349
- else:
321
+ elif sector is Sector.FOM:
350
322
  agents = self._preprocess_fom(agents)
351
323
 
352
324
  max_w = self.config.project.settings.CORES
@@ -354,25 +326,21 @@ class ValueFunctions:
354
326
 
355
327
  if max_w > 1:
356
328
  results_list = []
357
-
329
+ # with cf.ProcessPoolExecutor(max_workers=max_w, mp_context=multiprocessing.get_context("spawn")) as executor: # noqa
358
330
  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()
331
+ start = time.perf_counter()
362
332
  checkpoint = max(1, int(len(agents) * verb))
363
333
 
364
- # submit to worker
365
334
  futures = [
366
- executor.submit(worker, job, sector, self.config)
367
- for _, job in agents.iterrows()
335
+ executor.submit(worker, row, self.config, sector)
336
+ for _, row in agents.iterrows()
368
337
  ]
369
338
 
370
- # return results *as completed* - not in same order as input
371
339
  for f in cf.as_completed(futures):
372
340
  results_list.append(f.result())
373
341
 
374
342
  if len(results_list) % checkpoint == 0:
375
- sec_per_agent = (time.time() - start) / len(results_list)
343
+ sec_per_agent = (time.perf_counter() - start) / len(results_list)
376
344
  sec_per_agent = round(sec_per_agent, 3)
377
345
 
378
346
  eta = (sec_per_agent * (len(agents) - len(results_list))) / 60 / 60
@@ -385,23 +353,33 @@ class ValueFunctions:
385
353
  log.info(f"{sec_per_agent} seconds per agent")
386
354
  log.info(f"ETA: {eta} hours")
387
355
  else:
388
- results_list = [worker(job, sector, self.config) for _, job in agents.iterrows()]
356
+ results_list = [worker(job, self.config, sector) for _, job in agents.iterrows()]
389
357
 
390
358
  # 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)
359
+ results_df = pd.DataFrame.from_dict(
360
+ {k: v for d in results_list for k, v in d.items()}, orient="index"
361
+ )
362
+ results_df.index.name = "gid"
397
363
 
398
364
  # merge valuation results to agents dataframe
399
- agents = agents.merge(new_df, on="gid", how="left")
400
-
365
+ agents = agents.merge(results_df, on="gid", how="left")
401
366
  return agents
402
367
 
403
368
 
404
- def calc_financial_performance(capex_usd_p_kw, row, loan, batt_costs):
369
+ def calc_financial_performance(
370
+ capex_usd_p_kw: float, row: pd.Series, loan: cashloan, batt_costs: float
371
+ ) -> float:
372
+ """Calculates the net present value (NPV) for a single agent.
373
+
374
+ Args:
375
+ capex_usd_p_kw (float): Capital expenditures per kilowatt in USD (CapEx $/kW).
376
+ row (pd.Series): Single row of the agent DataFrame.
377
+ loan (:py:class:`PySAM.Cashloan`): Configured ``cashloan`` object.
378
+ batt_costs (float): Battery costs.
379
+
380
+ Returns:
381
+ float: The NPV of the agent.
382
+ """
405
383
  system_costs = capex_usd_p_kw * row["system_size_kw"]
406
384
 
407
385
  # calculate system costs
@@ -415,7 +393,18 @@ def calc_financial_performance(capex_usd_p_kw, row, loan, batt_costs):
415
393
  return loan.Outputs.npv
416
394
 
417
395
 
418
- def calc_financial_performance_fom(capex_usd_p_kw, row, financial):
396
+ def calc_financial_performance_fom(capex_usd_p_kw: float, row: pd.Series, financial: mp) -> float:
397
+ """Calculates the post-tax net present value (NPV) for a single agent.
398
+
399
+ Args:
400
+ capex_usd_p_kw (float): Capital expenditures per kilowatt in USD (CapEx $/kW).
401
+ row (pd.Series): Single row of the agent DataFrame.
402
+ financial (:py:class:`PySAM.Merchantplant`): Configured :py:class:`PVWattsMerchantplant``
403
+ or :py:class:`WindPowerMerchantplant`.
404
+
405
+ Returns:
406
+ float: The post-taxs NPV of the agent.
407
+ """
419
408
  system_costs = capex_usd_p_kw * row.loc["system_size_kw"]
420
409
 
421
410
  financial.SystemCosts.total_installed_cost = system_costs
@@ -426,7 +415,27 @@ def calc_financial_performance_fom(capex_usd_p_kw, row, financial):
426
415
  return financial.Outputs.project_return_aftertax_npv
427
416
 
428
417
 
429
- def find_cf_from_rev_wind(rev_dir, generation_scale_offset, tech_config, rev_index, year=2012):
418
+ def find_cf_from_rev_wind(
419
+ rev_dir: pathlib.Path,
420
+ generation_scale_offset: float,
421
+ tech_config: str,
422
+ rev_index: str,
423
+ year: int = 2018,
424
+ ) -> np.ndarray:
425
+ """Calculate the reV capacity factor.
426
+
427
+ Args:
428
+ rev_dir (pathlib.Path): Location of the pre-calculated reV results found at
429
+ ``Configuration.project.rev.DIR``
430
+ generation_scale_offset (float): Generation scaling offset found in the model configuration
431
+ at ``Configuration.project.settings.GENERATION_SCALE_OFFSET.wind``.
432
+ tech_config (str): Technology configuration string
433
+ rev_index (str): The "rev_index_wind" column value for the agent.
434
+ year (int, optional): reV generation year basis. Defaults to 2018.
435
+
436
+ Returns:
437
+ np.ndarray: Array of capacity factors.
438
+ """
430
439
  file_str = rev_dir / f"rev_{tech_config}_generation_{year}.h5"
431
440
 
432
441
  with h5.File(file_str, "r") as hf:
@@ -440,14 +449,33 @@ def find_cf_from_rev_wind(rev_dir, generation_scale_offset, tech_config, rev_ind
440
449
  scale_factor = generation_scale_offset
441
450
 
442
451
  cf_prof /= scale_factor
443
-
444
452
  return cf_prof
445
453
 
446
454
 
447
- def fetch_cambium_values(row, generation_hourly, cambium_dir, cambium_value, lower_thresh=0.01):
455
+ def fetch_cambium_values(
456
+ row: pd.Series,
457
+ generation_hourly: np.ndarray,
458
+ cambium_dir: pathlib.Path,
459
+ cambium_value: str,
460
+ lower_thresh: float = 0.01,
461
+ ) -> list[list[float, float], ...]:
462
+ """Retrieves the Cambium values as an 8760 x 2 tuple of tuples.
463
+
464
+ Args:
465
+ row (pd.Series): An individual row of the agent DataFrame.
466
+ generation_hourly (np.ndarray): Hourly generation data for a single year (length 8760).
467
+ cambium_dir (pathlib.Path): Full file path for the Cambium data.
468
+ cambium_value (str): Which Cambium value to retrieve.
469
+ lower_thresh (float, optional): Lower threshold for the MerchantPlant calculations.
470
+ Defaults to 0.01.
471
+
472
+ Returns:
473
+ list[list[float, float], ...]: Hourly, single year (8760) of the clipped generation, in MW,
474
+ and value, in $/MW.
475
+ """
448
476
  # read processed cambium dataframe from pickle
449
477
  cambium_f = cambium_dir / f"{row['cambium_scenario']}_pca_{row['yr']}_processed.pqt"
450
- cambium_df = pd.read_parquet(cambium_f)
478
+ cambium_df = pd.read_parquet(cambium_f, dtype_backend="pyarrow")
451
479
 
452
480
  cambium_df["year"] = cambium_df["year"].astype(str)
453
481
  cambium_df["pca"] = cambium_df["pca"].astype(str)
@@ -478,24 +506,21 @@ def fetch_cambium_values(row, generation_hourly, cambium_dir, cambium_value, low
478
506
  rev.loc[rev["cleared"] < rev["cleared"].max() * lower_thresh, "cleared"] = 0
479
507
  rev["cleared"] = rev["cleared"].apply(np.floor)
480
508
 
481
- rev = rev[["cleared", "value"]]
482
- tup = tuple(map(tuple, rev.values))
483
-
484
- return tup
509
+ return rev[["cleared", "value"]].values.tolist()
485
510
 
486
511
 
487
- def process_tariff(utilityrate, row, net_billing_sell_rate):
512
+ def process_tariff(utilityrate: ur5, row: pd.Series, net_billing_sell_rate: float) -> ur5:
488
513
  """Instantiate the utilityrate5 PySAM model and process the agent's
489
514
  rate information to conform with PySAM input formatting.
490
515
 
491
- Parameters
492
- ----------
493
- agent : 'pd.Series'
494
- Individual agent object.
516
+ Args:
517
+ utilityrate (:py:class:`PySAM.Utilityrate5`): Utilityrate5 model to be configured based
518
+ on the agent's specifications.
519
+ row (pd.Series): Individual agent row from the agent DataFrame.
520
+ net_billing_sell_rate (float): Net billing sell rate.
495
521
 
496
522
  Returns:
497
- -------
498
- utilityrate: 'PySAM.Utilityrate5'
523
+ PySAM.Utilityrate5: Configured Utilityrate5 model.
499
524
  """
500
525
  # Monthly fixed charge [$]
501
526
  utilityrate.ElectricityRates.ur_monthly_fixed_charge = row["ur_monthly_fixed_charge"]
@@ -548,13 +573,48 @@ def process_tariff(utilityrate, row, net_billing_sell_rate):
548
573
 
549
574
 
550
575
  def find_breakeven(
551
- row,
552
- loan,
553
- batt_costs,
554
- pysam_outputs,
555
- pre_calc_bounds_and_tolerances=True,
576
+ row: pd.Series,
577
+ loan: cashloan,
578
+ batt_costs: float,
579
+ pysam_outputs: list[str],
580
+ method: str,
581
+ *,
582
+ pre_calc_bounds_and_tolerances: bool = True,
556
583
  **kwargs,
557
- ):
584
+ ) -> pd.DataFrame | tuple[float, dict]:
585
+ """Calculates the breakeven cost and parameters for a distributed wind turbine in the agent's
586
+ location.
587
+
588
+ Args:
589
+ row (pd.Series): Single row of the agent DataFrame.
590
+ loan (:py:class:`PySAM.Cashloan`): Configured ``cashloan`` object.
591
+ batt_costs (float): Battery costs.
592
+ pysam_outputs (list[str]): List of PySAM output variables to return with the breakeven
593
+ cost.
594
+ method (str): Name of the optimization strategy. Must be on of "bisect", "brentq",
595
+ "grid_search", or "newton".
596
+ pre_calc_bounds_and_tolerances (bool, optional): Flag to pre-calculate the bounds for the
597
+ bisect adn brentq optimization methods.
598
+ **kwargs (dict): Inputs for each optimization strategy. For further parameterizations,
599
+ see the documentation for the "brentq", "bisect", and "newton" methods on the
600
+ `scipy optimize API reference site`_
601
+
602
+ # TODO: parameterize the default values.
603
+
604
+ Raises:
605
+ ValueError: Raised if the optimization was unable to find a viable solution
606
+ ValueError: Raised for an unknown :py:attr:`method`.
607
+
608
+ Returns:
609
+ pd.DataFrame: Returned when :py:attr:`method` is "grid_search"
610
+ tuple[float, dict]: Returns the breakeven cost and PySAM outputs specified in
611
+ :py:attr:`pysam_outputs` when :py:attr:`method` is "brentq", "bisect", or "newton".
612
+
613
+ .. _scipy optimize API reference site:
614
+ https://docs.scipy.org/doc/scipy/reference/optimize.html#scalar-functions
615
+ """
616
+ method = Optimization(method)
617
+
558
618
  # calculate theoretical min/max NPV values
559
619
  min_npv = calc_financial_performance(1e9, row, loan, batt_costs)
560
620
 
@@ -596,7 +656,7 @@ def find_breakeven(
596
656
  # pre-calculate tolerance as a proportion of capex value pre-calculated directly above
597
657
  tol = 1e-6 * ((a + b) / 2)
598
658
 
599
- if kwargs["method"] == "grid_search":
659
+ if method is Optimization.GRID_SEARCH:
600
660
  if kwargs["capex_array"] is None:
601
661
  capex_array = np.arange(5000.0, -500.0, -500.0)
602
662
  else:
@@ -614,7 +674,8 @@ def find_breakeven(
614
674
 
615
675
  except Exception as e:
616
676
  raise ValueError("Grid search failed.") from e
617
- elif kwargs["method"] == "bisect":
677
+
678
+ if method is Optimization.BISECT:
618
679
  try:
619
680
  # required args for 'bisect'
620
681
  if not pre_calc_bounds_and_tolerances:
@@ -642,14 +703,13 @@ def find_breakeven(
642
703
  full_output=full_output,
643
704
  disp=disp,
644
705
  )
645
-
646
- return breakeven_cost_usd_p_kw, {
647
- k: loan.Outputs.export().get(k, None) for k in pysam_outputs
648
- }
706
+ results = {k: getattr(loan.Outputs, k) for k in pysam_outputs}
707
+ return breakeven_cost_usd_p_kw, results
649
708
 
650
709
  except Exception as e:
651
710
  raise ValueError("Root finding failed.") from e
652
- elif kwargs["method"] == "brentq":
711
+
712
+ if method is Optimization.BRENTQ:
653
713
  try:
654
714
  # required args for 'brentq'
655
715
  if not pre_calc_bounds_and_tolerances:
@@ -678,14 +738,13 @@ def find_breakeven(
678
738
  disp=disp,
679
739
  )
680
740
 
681
- return breakeven_cost_usd_p_kw, {
682
- k: loan.Outputs.export().get(k, None) for k in pysam_outputs
683
- }
741
+ results = {k: getattr(loan.Outputs, k) for k in pysam_outputs}
742
+ return breakeven_cost_usd_p_kw, results
684
743
 
685
744
  except Exception as e:
686
745
  raise ValueError("Root finding failed.") from e
687
746
 
688
- elif kwargs["method"] == "newton":
747
+ if method is Optimization.NEWTON:
689
748
  try:
690
749
  # required args for 'newton'
691
750
  x0 = kwargs["x0"]
@@ -719,24 +778,57 @@ def find_breakeven(
719
778
  disp=disp,
720
779
  )
721
780
 
722
- return breakeven_cost_usd_p_kw, {
723
- k: loan.Outputs.export().get(k, None) for k in pysam_outputs
724
- }
781
+ results = {k: getattr(loan.Outputs, k) for k in pysam_outputs}
782
+ return breakeven_cost_usd_p_kw, results
725
783
 
726
784
  except Exception as e:
727
785
  raise ValueError("Root finding failed.") from e
728
786
 
729
- else:
730
- raise ValueError("Invalid method passed to find_breakeven function")
787
+ raise ValueError(f"Invalid `method` ({method}) passed to `find_breakeven` function")
731
788
 
732
789
 
733
790
  def find_breakeven_fom(
734
- row,
735
- financial,
736
- pysam_outputs,
737
- pre_calc_bounds_and_tolerances=True,
791
+ row: pd.Series,
792
+ financial: mp,
793
+ pysam_outputs: list[str],
794
+ method: str,
795
+ *,
796
+ pre_calc_bounds_and_tolerances: bool = True,
738
797
  **kwargs,
739
- ):
798
+ ) -> pd.DataFrame | tuple[float, dict]:
799
+ """Calculates the breakeven cost and parameters for a front-of-meter distributed wind turbine
800
+ in the agent's location.
801
+
802
+ Args:
803
+ row (pd.Series): Single row of the agent DataFrame.
804
+ financial (:py:class:`PySAM.Merchantplant`): Configured ``PVWattsMerchantPlant`` or
805
+ :py:class:`PySAM.WindPowerMerchantplant` object.
806
+ pysam_outputs (list[str]): List of PySAM output variables to return with the breakeven
807
+ cost.
808
+ method (str): Name of the optimization strategy. Must be on of "bisect", "brentq",
809
+ "grid_search", or "newton".
810
+ pre_calc_bounds_and_tolerances (bool, optional): Flag to pre-calculate the bounds for the
811
+ bisect adn brentq optimization methods.
812
+ **kwargs (dict): Inputs for each optimization strategy. For further parameterizations,
813
+ see the documentation for the "brentq", "bisect", and "newton" methods on the
814
+ `scipy optimize API reference site`_
815
+
816
+ # TODO: parameterize the default values.
817
+
818
+ Raises:
819
+ ValueError: Raised if the optimization was unable to find a viable solution
820
+ ValueError: Raised for an unknown :py:attr:`method`.
821
+
822
+ Returns:
823
+ pd.DataFrame: Returned when :py:attr:`method` is "grid_search"
824
+ tuple[float, dict]: Returns the breakeven cost and PySAM outputs specified in
825
+ :py:attr:`pysam_outputs` when :py:attr:`method` is "brentq", "bisect", or "newton".
826
+
827
+ .. _scipy optimize API reference site:
828
+ https://docs.scipy.org/doc/scipy/reference/optimize.html#scalar-functions
829
+ """
830
+ method = Optimization(method)
831
+
740
832
  # calculate theoretical min/max NPV values
741
833
  min_npv = calc_financial_performance_fom(1e9, row, financial)
742
834
 
@@ -778,7 +870,7 @@ def find_breakeven_fom(
778
870
  # capex value pre-calculated directly above
779
871
  tol = 1e-6 * ((a + b) / 2)
780
872
 
781
- if kwargs["method"] == "grid_search":
873
+ if method is Optimization.GRID_SEARCH:
782
874
  if kwargs["capex_array"] is None:
783
875
  capex_array = np.arange(5000.0, -500.0, -500.0)
784
876
  else:
@@ -796,7 +888,8 @@ def find_breakeven_fom(
796
888
 
797
889
  except Exception as e:
798
890
  raise ValueError("Grid search failed.") from e
799
- elif kwargs["method"] == "bisect":
891
+
892
+ if method is Optimization.BISECT:
800
893
  try:
801
894
  # required args for 'bisect
802
895
  if not pre_calc_bounds_and_tolerances:
@@ -825,13 +918,13 @@ def find_breakeven_fom(
825
918
  disp=disp,
826
919
  )
827
920
 
828
- return breakeven_cost_usd_p_kw, {
829
- k: financial.Outputs.export().get(k, None) for k in pysam_outputs
830
- }
921
+ results = {k: getattr(financial.Outputs, k) for k in pysam_outputs}
922
+ return breakeven_cost_usd_p_kw, results
831
923
 
832
924
  except Exception as e:
833
925
  raise ValueError("Root finding failed.") from e
834
- elif kwargs["method"] == "brentq":
926
+
927
+ if method is Optimization.BRENTQ:
835
928
  try:
836
929
  # required args for 'brentq'
837
930
  if not pre_calc_bounds_and_tolerances:
@@ -860,13 +953,13 @@ def find_breakeven_fom(
860
953
  disp=disp,
861
954
  )
862
955
 
863
- return breakeven_cost_usd_p_kw, {
864
- k: financial.Outputs.export().get(k, None) for k in pysam_outputs
865
- }
956
+ results = {k: getattr(financial.Outputs, k) for k in pysam_outputs}
957
+ return breakeven_cost_usd_p_kw, results
866
958
 
867
959
  except Exception as e:
868
960
  raise ValueError("Root finding failed.") from e
869
- elif kwargs["method"] == "newton":
961
+
962
+ if method is Optimization.NEWTON:
870
963
  try:
871
964
  # required args for 'newton'
872
965
  x0 = kwargs["x0"]
@@ -900,57 +993,46 @@ def find_breakeven_fom(
900
993
  disp=disp,
901
994
  )
902
995
 
903
- return breakeven_cost_usd_p_kw, {
904
- k: financial.Outputs.export().get(k, None) for k in pysam_outputs
905
- }
996
+ results = {k: getattr(financial.Outputs, k) for k in pysam_outputs}
997
+ return breakeven_cost_usd_p_kw, results
906
998
 
907
999
  except Exception as e:
908
1000
  raise ValueError("Root finding failed") from e
909
- else:
910
- raise ValueError("Invalid method passed to find_breakeven function")
1001
+
1002
+ raise ValueError("Invalid method passed to find_breakeven function")
911
1003
 
912
1004
 
913
1005
  def process_btm(
914
- row,
915
- tech,
916
- generation_hourly,
917
- consumption_hourly,
918
- pysam_outputs,
1006
+ row: pd.Series,
1007
+ tech: Technology,
1008
+ generation_hourly: np.ndarray,
1009
+ consumption_hourly: np.ndarray,
1010
+ pysam_outputs: list[str],
1011
+ batt_dispatch: str | None = None,
1012
+ *,
919
1013
  en_batt=False,
920
- batt_dispatch=None,
921
1014
  ):
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
1015
+ """Behind-the-meter calculation for a single agent.
943
1016
 
944
- Returns:
945
- -------
1017
+ TODO: list out the actual process
946
1018
 
1019
+ Args:
1020
+ row (pandas.Series): The row of the dataframe on which the function is performed.
1021
+ tech (dwind.config.Technology): Validated :py:class:`dwind.config.Technology` object.
1022
+ generation_hourly (np.ndarray): Hourly generation for a single year (8760 hours).
1023
+ consumption_hourly (np.ndarray): Hourly consumption for a single year (8760 hours).
1024
+ pysam_outputs (list[str]): List of desired PySAM output data.
1025
+ batt_dispatch (str, optional): Battery dispatch strategy type.
1026
+ en_batt (bool, optional): Enable battery modeling if True. Defaults to False.
1027
+
1028
+ Returns:
1029
+ pd.Series: Updated :py:attr:`row` with PySAM and breakeven cost results.
947
1030
  """
948
1031
  # extract agent load and generation profiles
949
1032
  generation_hourly = np.array(generation_hourly)
950
- consumption_hourly = np.array(consumption_hourly, dtype=np.float32)
951
1033
 
952
1034
  # specify tech-agnostic system size column
953
- row["system_size_kw"] = row[f"{tech}_size_kw_btm"]
1035
+ row["system_size_kw"] = row[f"{tech.value}_size_kw_btm"]
954
1036
 
955
1037
  # instantiate PySAM battery model based on agent sector
956
1038
  if row.loc["sector_abbr"] == "res":
@@ -1312,7 +1394,7 @@ def process_btm(
1312
1394
 
1313
1395
  _ = calc_financial_performance(row["system_capex_per_kw"], row, loan, batt_costs)
1314
1396
 
1315
- row["additional_pysam_outputs"] = {k: loan.Outputs.export().get(k, None) for k in pysam_outputs}
1397
+ row["additional_pysam_outputs"] = {k: getattr(loan.Outputs, k) for k in pysam_outputs}
1316
1398
 
1317
1399
  # run root finding algorithm to find breakeven cost based on calculated NPV
1318
1400
  out, _ = find_breakeven(
@@ -1320,8 +1402,9 @@ def process_btm(
1320
1402
  loan=loan,
1321
1403
  pysam_outputs=pysam_outputs,
1322
1404
  batt_costs=batt_costs,
1405
+ method="newton",
1323
1406
  pre_calc_bounds_and_tolerances=False,
1324
- **{"method": "newton", "x0": 10000.0, "full_output": True},
1407
+ **{"x0": 10000.0, "full_output": True},
1325
1408
  )
1326
1409
 
1327
1410
  row["breakeven_cost_usd_p_kw"] = out
@@ -1330,40 +1413,39 @@ def process_btm(
1330
1413
 
1331
1414
 
1332
1415
  def process_fom(
1333
- row,
1334
- tech,
1335
- generation_hourly,
1336
- market_profile,
1337
- pysam_outputs,
1338
- en_batt=False,
1339
- batt_dispatch=None,
1416
+ row: pd.Series,
1417
+ tech: Technology,
1418
+ generation_hourly: np.ndarray,
1419
+ market_profile: list[list[float, float], ...],
1420
+ pysam_outputs: list[str],
1421
+ batt_dispatch: str | None = None,
1422
+ *,
1423
+ en_batt: bool = False,
1340
1424
  ):
1341
- """Front-of-the-meter ...
1425
+ """Front-of-meter calculation for a single agent.
1342
1426
 
1343
- This function processes a FOM agent by:
1344
- 1)
1427
+ TODO: list out the actual process
1345
1428
 
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
1429
+ Args:
1430
+ row (pandas.Series): The row of the dataframe on which the function is performed.
1431
+ tech (dwind.config.Technology): Validated :py:class:`dwind.config.Technology` object.
1432
+ generation_hourly (np.ndarray): Hourly generation for a single year (8760 hours).
1433
+ market_profile (list[list[float, float], ...]): Hourly Cambium values for a single year.
1434
+ pysam_outputs (list[str]): List of desired PySAM output data.
1435
+ batt_dispatch (str, optional): Battery dispatch strategy type.
1436
+ en_batt (bool, optional): Enable battery modeling if True. Defaults to False.
1354
1437
 
1355
1438
  Returns:
1356
- -------
1357
-
1439
+ pd.Series: Updated :py:attr:`row` with PySAM and breakeven cost results.
1358
1440
  """
1359
1441
  # extract generation profile
1360
1442
  generation_hourly = np.array(generation_hourly)
1361
1443
 
1362
1444
  # specify tech-agnostic system size column
1363
- row["system_size_kw"] = row[f"{tech}_size_kw_fom"]
1445
+ row["system_size_kw"] = row[f"{tech.value}_size_kw_fom"]
1364
1446
 
1365
1447
  inv_eff = 1.0 # required inverter efficiency for FOM systems
1366
- gen = [i * inv_eff for i in generation_hourly]
1448
+ gen = (generation_hourly * inv_eff).tolist()
1367
1449
 
1368
1450
  # set up battery, with system generation conditional
1369
1451
  # on the battery generation being included
@@ -1372,15 +1454,15 @@ def process_fom(
1372
1454
  pass
1373
1455
  else:
1374
1456
  # initialize PySAM model
1375
- if tech == "solar":
1457
+ if tech is Technology.SOLAR:
1376
1458
  financial = mp.default("PVWattsMerchantPlant")
1377
- elif tech == "wind":
1459
+ elif tech is Technology.WIND:
1378
1460
  financial = mp.default("WindPowerMerchantPlant")
1379
1461
  else:
1380
1462
  msg = "Please write a wrapper to account for the new technology type"
1381
- raise NotImplementedError(f"{msg} {tech}")
1463
+ raise NotImplementedError(f"{msg} {tech.value}")
1382
1464
 
1383
- ptc_fed_amt = row[f"ptc_fed_amt_{tech}"]
1465
+ ptc_fed_amt = row[f"ptc_fed_amt_{tech.value}"]
1384
1466
  itc_fed_pct = row["itc_fed_pct"]
1385
1467
  deg = row["deg"]
1386
1468
  system_capex_per_kw = row["system_capex_per_kw"]
@@ -1448,10 +1530,12 @@ def process_fom(
1448
1530
  financial.Revenue.mp_enable_ancserv2 = 0
1449
1531
  financial.Revenue.mp_enable_ancserv3 = 0
1450
1532
  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))]
1533
+
1534
+ N = len(market_profile)
1535
+ financial.Revenue.mp_ancserv1_revenue = [(0, 0)] * N
1536
+ financial.Revenue.mp_ancserv2_revenue = [(0, 0)] * N
1537
+ financial.Revenue.mp_ancserv3_revenue = [(0, 0)] * N
1538
+ financial.Revenue.mp_ancserv4_revenue = [(0, 0)] * N
1455
1539
 
1456
1540
  financial.CapacityPayments.cp_capacity_payment_type = 0
1457
1541
  financial.CapacityPayments.cp_capacity_payment_amount = [0]
@@ -1469,9 +1553,7 @@ def process_fom(
1469
1553
 
1470
1554
  log.info(f"row {row.loc['gid']} calculating financial performance")
1471
1555
  _ = 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
- }
1556
+ row["additional_pysam_outputs"] = {k: getattr(financial.Outputs, k) for k in pysam_outputs}
1475
1557
 
1476
1558
  # run root finding algorithm to find breakeven cost based on calculated NPV
1477
1559
  log.info(f"row {row.loc['gid']} breakeven")
@@ -1488,52 +1570,76 @@ def process_fom(
1488
1570
  return row
1489
1571
 
1490
1572
 
1491
- def worker(row: pd.Series, sector: str, config: Configuration):
1573
+ def worker(row: pd.Series, config: Configuration, sector: Sector) -> tuple[str, dict]:
1574
+ """Individual calculation process meant to be run in parallel.
1575
+
1576
+ Args:
1577
+ row (pd.Series): Individual row of from the agent DataFraem to analyze.
1578
+ config (Configuration): The overarching dwind configuration.
1579
+ sector (Sector): One of "fom" or "btm".
1580
+
1581
+ Returns:
1582
+ tuple[str, dict]: "gid" column of :py:attr:`row` and the dictionary
1583
+ of results for that agent.
1584
+ """
1492
1585
  try:
1493
1586
  results = {}
1494
1587
  for tech in config.project.settings.TECHS:
1495
- if tech == "wind":
1588
+ tech = Technology(tech)
1589
+ if tech is Technology.WIND:
1496
1590
  tech_config = row["turbine_class"]
1497
- elif tech == "solar":
1591
+ elif tech is Technology.SOLAR:
1498
1592
  # 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)
1593
+ azimuth = config.rev.settings.azimuth_direction_to_degree[
1594
+ row[f"azimuth_{sector.value}"]
1595
+ ]
1596
+ tilt = row[f"tilt_{sector.value}"]
1597
+ str(azimuth) + "_" + str(tilt)
1502
1598
 
1503
- if row[f"{tech}_size_kw_{sector}"] > 0:
1599
+ if row[f"{tech.value}_size_kw_{sector.value}"] > 0:
1504
1600
  tech_config = row["turbine_class"]
1505
1601
  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}"],
1602
+ rev_dir=config.rev.DIR,
1603
+ generation_scale_offset=config.project.settings.GENERATION_SCALE_OFFSET["wind"],
1604
+ rev_index=row[f"rev_index_{tech.value}"],
1605
+ tech_config=tech_config,
1510
1606
  )
1511
- generation_hourly = cf_hourly * row[f"{tech}_size_kw_{sector}"]
1607
+ generation_hourly = cf_hourly * row[f"{tech.value}_size_kw_{sector.value}"]
1512
1608
  generation_hourly = generation_hourly.round(2)
1513
1609
  generation_hourly = np.array(generation_hourly).astype(np.float32).round(2)
1514
1610
  else:
1515
1611
  # 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"}
1612
+ results[f"{tech.value}_breakeven_cost_{sector.value}"] = -1
1613
+ results[f"{tech.value}_pysam_outputs_{sector.value}"] = {
1614
+ "msg": "System size is zero"
1615
+ }
1518
1616
  continue
1519
1617
 
1520
- if sector == "btm":
1618
+ if sector is Sector.BTM:
1619
+ with h5.File("/projects/dwind/data/crb_consumption_hourly.h5") as hf:
1620
+ c, h = row.crb_model_index, row.hdf_index
1621
+ consumption_hourly = hf["consumption"][c, h, :].astype(np.float32)
1622
+ consumption_hourly /= 1e8
1623
+ consumption_hourly = (
1624
+ consumption_hourly / consumption_hourly.sum() * row.load_kwh
1625
+ )
1626
+
1521
1627
  row = process_btm(
1522
1628
  row=row,
1523
1629
  tech=tech,
1524
1630
  generation_hourly=generation_hourly,
1525
- consumption_hourly=row["consumption_hourly"],
1631
+ consumption_hourly=consumption_hourly,
1526
1632
  pysam_outputs=config.pysam.outputs.btm,
1527
1633
  en_batt=False,
1528
1634
  batt_dispatch=None, # TODO: enable battery switch earlier
1529
1635
  )
1530
- else:
1636
+ elif sector is Sector.FOM:
1531
1637
  # fetch 8760 cambium values for FOM
1532
1638
  market_profile = fetch_cambium_values(
1533
1639
  row,
1534
1640
  generation_hourly,
1535
1641
  config.project.settings.CAMBIUM_DATA_DIR,
1536
- config.CAMBIUM_VALUE,
1642
+ config.project.settings.CAMBIUM_VALUE,
1537
1643
  )
1538
1644
 
1539
1645
  row = process_fom(
@@ -1547,10 +1653,10 @@ def worker(row: pd.Series, sector: str, config: Configuration):
1547
1653
  )
1548
1654
 
1549
1655
  # 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"]
1656
+ results[f"{tech.value}_breakeven_cost_{sector.value}"] = row["breakeven_cost_usd_p_kw"]
1657
+ results |= row["additional_pysam_outputs"]
1552
1658
 
1553
- return (row["gid"], results)
1659
+ return {row["gid"]: results}
1554
1660
 
1555
1661
  except Exception as e:
1556
1662
  log.exception(e)