dwind 0.3.1__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,6 +1,10 @@
1
- import os
1
+ """Provides the core value calculation methods."""
2
+
3
+ from __future__ import annotations
4
+
2
5
  import time
3
6
  import logging
7
+ import pathlib
4
8
  import functools
5
9
  import concurrent.futures as cf
6
10
 
@@ -14,32 +18,37 @@ import PySAM.Utilityrate5 as ur5
14
18
  import PySAM.Merchantplant as mp
15
19
  from scipy import optimize
16
20
 
17
- 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
18
24
 
19
25
 
20
26
  log = logging.getLogger("dwfs")
21
27
 
22
28
 
23
29
  class ValueFunctions:
30
+ """Primary model calculation engine responsible for the computation of individual agents."""
31
+
24
32
  def __init__(
25
33
  self,
26
- scenario: str,
27
- year: int,
34
+ scenario: str | Scenario,
35
+ year: int | Year,
28
36
  configuration: Configuration,
29
- return_format="totals",
37
+ return_format: str = "totals",
30
38
  ):
31
- """Primary model calculation engine responsible for the computation of individual agents.
39
+ """Creates an instance of the valuation class.
32
40
 
33
41
  Args:
34
42
  scenario (str): Only option is "baseline" currently.
35
43
  year (int): Analysis year.
36
44
  configuration (dwind.config.Configuration): Model configuration with universal settings.
37
- return_format ['profiles', 'total_profile', 'totals', 'total'] -
38
- Return individual value stream 8760s, a cumulative value stream 8760,
39
- 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".
40
49
  """
41
- self.scenario = scenario
42
- self.year = year
50
+ self.scenario = Scenario(scenario)
51
+ self.year = Year(year)
43
52
  self.config = configuration
44
53
  self.return_format = return_format
45
54
 
@@ -51,6 +60,7 @@ class ValueFunctions:
51
60
  self.load()
52
61
 
53
62
  def load(self):
63
+ """Loads all the core data from CSV and SQL for configuring PySAM."""
54
64
  _load_csv = functools.partial(loader.load_df, year=self.year)
55
65
  _load_sql = functools.partial(
56
66
  loader.load_df,
@@ -121,9 +131,18 @@ class ValueFunctions:
121
131
  costs["system_variable_om_per_kw"] = cost_inputs["system_variable_om_per_kw"][tech]
122
132
  return costs
123
133
 
124
- def _preprocess_btm(self, df, tech="wind"):
125
- # sec = row['sector_abbr']
126
- # 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)
127
146
  df["county_id_int"] = df.county_id.astype(int)
128
147
 
129
148
  # Get the electricity rates
@@ -144,8 +163,12 @@ class ValueFunctions:
144
163
  df = df.drop(columns="county_id_int")
145
164
 
146
165
  # Technology-specific factors
147
- tech_join = "sector_abbr" if tech == "solar" else "wind_turbine_kw_btm"
148
- 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)
149
172
  df = pd.merge(
150
173
  df,
151
174
  cost_df,
@@ -153,15 +176,15 @@ class ValueFunctions:
153
176
  left_on=tech_join,
154
177
  right_on=tech_join,
155
178
  )
156
- if tech == "solar":
179
+ if tech is Technology.SOLAR:
157
180
  df = pd.merge(
158
181
  df,
159
- self.PERFORMANCE_INPUTS[tech],
182
+ self.PERFORMANCE_INPUTS[tech.value],
160
183
  how="left",
161
184
  left_on=tech_join,
162
185
  right_on=tech_join,
163
186
  )
164
- else:
187
+ elif tech is Technology.WIND:
165
188
  df = pd.merge(
166
189
  df,
167
190
  self.wind_tech_inputs[["turbine_size_kw", "perf_improvement_factor"]],
@@ -240,9 +263,20 @@ class ValueFunctions:
240
263
  return df
241
264
 
242
265
  def _preprocess_fom(self, df, tech="wind"):
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".
272
+
273
+ Returns:
274
+ pd.DataFrame: Updated agent data with key financial data attached.
275
+ """
276
+ tech = Technology(tech)
243
277
  itc_fraction_of_capex = self.FINANCIAL_INPUTS["FOM"]["itc_fraction_of_capex"]
244
278
  df = df.assign(
245
- yr=self.year,
279
+ yr=self.year.value,
246
280
  cambium_scenario=self.CAMBIUM_SCENARIO,
247
281
  analysis_period=self.FINANCIAL_INPUTS["FOM"]["system_lifetime"],
248
282
  debt_option=self.FINANCIAL_INPUTS["FOM"]["debt_option"],
@@ -254,9 +288,13 @@ class ValueFunctions:
254
288
  term_tenor=self.FINANCIAL_INPUTS["FOM"]["system_lifetime"],
255
289
  itc_fed_pct=itc_fraction_of_capex if self.year != 2025 else 0.3,
256
290
  deg=self.FINANCIAL_INPUTS["FOM"]["degradation"],
257
- system_capex_per_kw=self.COST_INPUTS["FOM"]["system_capex_per_kw"][tech],
258
- system_om_per_kw=self.COST_INPUTS["FOM"]["system_om_per_kw"][tech],
259
- **{f"ptc_fed_amt_{tech}": self.FINANCIAL_INPUTS["FOM"]["ptc_fed_dlrs_per_kwh"][tech]},
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
+ },
260
298
  )
261
299
  # 2025 uses census-tract based applicable credit for the itc_fed_pct, so update accordingly
262
300
  if self.year == 2025:
@@ -267,96 +305,42 @@ class ValueFunctions:
267
305
 
268
306
  return df
269
307
 
270
- def run(self, agents: pd.DataFrame, sector: str):
271
- # self._connect_to_sql()
272
-
273
- max_w = self.config.project.settings.THREAD_WORKERS
274
- verb = self.config.project.settings.VERBOSITY
275
-
276
- if max_w > 1:
277
- results_list = []
278
-
279
- # btw, multithreading is NOT multiprocessing
280
- with cf.ThreadPoolExecutor(max_workers=max_w) as executor:
281
- # log.info(
282
- # f'....beginning multiprocess execution of valuation with {max_w} threads')
283
- log.info(f"....beginning execution of valuation with {max_w} threads")
284
-
285
- start = time.time()
286
- checkpoint = max(1, int(len(agents) * verb))
287
-
288
- # submit to worker
289
- futures = [
290
- executor.submit(self.worker, job, sector, self.config)
291
- for _, job in agents.iterrows()
292
- ]
293
-
294
- # return results *as completed* - not in same order as input
295
- for f in cf.as_completed(futures):
296
- results_list.append(f.result())
297
- if len(results_list) % checkpoint == 0:
298
- sec_per_agent = (time.time() - start) / len(results_list)
299
- sec_per_agent = round(sec_per_agent, 3)
300
-
301
- eta = (sec_per_agent * (len(agents) - len(results_list))) / 60 / 60
302
- eta = round(eta, 2)
303
-
304
- l_results = len(results_list)
305
- l_agents = len(agents)
306
-
307
- log.info(f"........finished job {l_results} / {l_agents}")
308
- log.info(f"{sec_per_agent} seconds per agent")
309
- log.info(f"ETA: {eta} hours")
310
- else:
311
- results_list = [self.worker(job, sector, self.config) for _, job in agents.iterrows()]
312
-
313
- # create results df from workers
314
- new_index = [r[0] for r in results_list]
315
- new_dicts = [r[1] for r in results_list]
316
-
317
- new_df = pd.DataFrame(new_dicts)
318
- new_df["gid"] = new_index
319
- new_df.set_index("gid", inplace=True)
320
-
321
- # merge valuation results to agents dataframe
322
- agents = agents.merge(new_df, on="gid", how="left")
308
+ def run(self, agents: pd.DataFrame, sector: Sector):
309
+ """Run a multi-threaded PySAM analysis on each agent.
323
310
 
324
- 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).
325
315
 
326
- def run_multiprocessing(self, agents, sector):
327
- # uses cf.ProcessPoolExecutor rather than cf.ThreadPoolExecutor in run.py
328
- 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:
329
320
  agents = self._preprocess_btm(agents)
330
- else:
321
+ elif sector is Sector.FOM:
331
322
  agents = self._preprocess_fom(agents)
332
323
 
333
324
  max_w = self.config.project.settings.CORES
334
325
  verb = self.config.project.settings.VERBOSITY
335
326
 
336
327
  if max_w > 1:
337
- # Override project-level setting to ensure memory intensive calculations don't
338
- # cause jobs to silently fail
339
- max_w = min(int(os.cpu_count() * 0.8), max_w)
340
328
  results_list = []
341
-
329
+ # with cf.ProcessPoolExecutor(max_workers=max_w, mp_context=multiprocessing.get_context("spawn")) as executor: # noqa
342
330
  with cf.ProcessPoolExecutor(max_workers=max_w) as executor:
343
- log.info(f"....beginning multiprocess execution of valuation with {max_w} cores")
344
-
345
- start = time.time()
331
+ start = time.perf_counter()
346
332
  checkpoint = max(1, int(len(agents) * verb))
347
333
 
348
- # submit to worker
349
334
  futures = [
350
- executor.submit(worker, job, sector, self.config)
351
- for _, job in agents.iterrows()
335
+ executor.submit(worker, row, self.config, sector)
336
+ for _, row in agents.iterrows()
352
337
  ]
353
338
 
354
- # return results *as completed* - not in same order as input
355
339
  for f in cf.as_completed(futures):
356
340
  results_list.append(f.result())
357
341
 
358
342
  if len(results_list) % checkpoint == 0:
359
- sec_per_agent = (time.time() - start) / len(results_list)
343
+ sec_per_agent = (time.perf_counter() - start) / len(results_list)
360
344
  sec_per_agent = round(sec_per_agent, 3)
361
345
 
362
346
  eta = (sec_per_agent * (len(agents) - len(results_list))) / 60 / 60
@@ -369,23 +353,33 @@ class ValueFunctions:
369
353
  log.info(f"{sec_per_agent} seconds per agent")
370
354
  log.info(f"ETA: {eta} hours")
371
355
  else:
372
- 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()]
373
357
 
374
358
  # create results df from workers
375
- new_index = [r[0] for r in results_list]
376
- new_dicts = [r[1] for r in results_list]
377
-
378
- new_df = pd.DataFrame(new_dicts)
379
- new_df["gid"] = new_index
380
- 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"
381
363
 
382
364
  # merge valuation results to agents dataframe
383
- agents = agents.merge(new_df, on="gid", how="left")
384
-
365
+ agents = agents.merge(results_df, on="gid", how="left")
385
366
  return agents
386
367
 
387
368
 
388
- 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
+ """
389
383
  system_costs = capex_usd_p_kw * row["system_size_kw"]
390
384
 
391
385
  # calculate system costs
@@ -399,7 +393,18 @@ def calc_financial_performance(capex_usd_p_kw, row, loan, batt_costs):
399
393
  return loan.Outputs.npv
400
394
 
401
395
 
402
- 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
+ """
403
408
  system_costs = capex_usd_p_kw * row.loc["system_size_kw"]
404
409
 
405
410
  financial.SystemCosts.total_installed_cost = system_costs
@@ -410,7 +415,27 @@ def calc_financial_performance_fom(capex_usd_p_kw, row, financial):
410
415
  return financial.Outputs.project_return_aftertax_npv
411
416
 
412
417
 
413
- 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
+ """
414
439
  file_str = rev_dir / f"rev_{tech_config}_generation_{year}.h5"
415
440
 
416
441
  with h5.File(file_str, "r") as hf:
@@ -424,14 +449,33 @@ def find_cf_from_rev_wind(rev_dir, generation_scale_offset, tech_config, rev_ind
424
449
  scale_factor = generation_scale_offset
425
450
 
426
451
  cf_prof /= scale_factor
427
-
428
452
  return cf_prof
429
453
 
430
454
 
431
- 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
+ """
432
476
  # read processed cambium dataframe from pickle
433
477
  cambium_f = cambium_dir / f"{row['cambium_scenario']}_pca_{row['yr']}_processed.pqt"
434
- cambium_df = pd.read_parquet(cambium_f)
478
+ cambium_df = pd.read_parquet(cambium_f, dtype_backend="pyarrow")
435
479
 
436
480
  cambium_df["year"] = cambium_df["year"].astype(str)
437
481
  cambium_df["pca"] = cambium_df["pca"].astype(str)
@@ -462,24 +506,21 @@ def fetch_cambium_values(row, generation_hourly, cambium_dir, cambium_value, low
462
506
  rev.loc[rev["cleared"] < rev["cleared"].max() * lower_thresh, "cleared"] = 0
463
507
  rev["cleared"] = rev["cleared"].apply(np.floor)
464
508
 
465
- rev = rev[["cleared", "value"]]
466
- tup = tuple(map(tuple, rev.values.tolist()))
467
-
468
- return tup
509
+ return rev[["cleared", "value"]].values.tolist()
469
510
 
470
511
 
471
- def process_tariff(utilityrate, row, net_billing_sell_rate):
512
+ def process_tariff(utilityrate: ur5, row: pd.Series, net_billing_sell_rate: float) -> ur5:
472
513
  """Instantiate the utilityrate5 PySAM model and process the agent's
473
514
  rate information to conform with PySAM input formatting.
474
515
 
475
- Parameters
476
- ----------
477
- agent : 'pd.Series'
478
- 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.
479
521
 
480
522
  Returns:
481
- -------
482
- utilityrate: 'PySAM.Utilityrate5'
523
+ PySAM.Utilityrate5: Configured Utilityrate5 model.
483
524
  """
484
525
  # Monthly fixed charge [$]
485
526
  utilityrate.ElectricityRates.ur_monthly_fixed_charge = row["ur_monthly_fixed_charge"]
@@ -532,13 +573,48 @@ def process_tariff(utilityrate, row, net_billing_sell_rate):
532
573
 
533
574
 
534
575
  def find_breakeven(
535
- row,
536
- loan,
537
- batt_costs,
538
- pysam_outputs,
539
- 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,
540
583
  **kwargs,
541
- ):
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
+
542
618
  # calculate theoretical min/max NPV values
543
619
  min_npv = calc_financial_performance(1e9, row, loan, batt_costs)
544
620
 
@@ -580,7 +656,7 @@ def find_breakeven(
580
656
  # pre-calculate tolerance as a proportion of capex value pre-calculated directly above
581
657
  tol = 1e-6 * ((a + b) / 2)
582
658
 
583
- if kwargs["method"] == "grid_search":
659
+ if method is Optimization.GRID_SEARCH:
584
660
  if kwargs["capex_array"] is None:
585
661
  capex_array = np.arange(5000.0, -500.0, -500.0)
586
662
  else:
@@ -598,7 +674,8 @@ def find_breakeven(
598
674
 
599
675
  except Exception as e:
600
676
  raise ValueError("Grid search failed.") from e
601
- elif kwargs["method"] == "bisect":
677
+
678
+ if method is Optimization.BISECT:
602
679
  try:
603
680
  # required args for 'bisect'
604
681
  if not pre_calc_bounds_and_tolerances:
@@ -626,12 +703,13 @@ def find_breakeven(
626
703
  full_output=full_output,
627
704
  disp=disp,
628
705
  )
629
- results = loan.Outputs.export()
630
- return breakeven_cost_usd_p_kw, {k: results.get(k) for k in pysam_outputs}
706
+ results = {k: getattr(loan.Outputs, k) for k in pysam_outputs}
707
+ return breakeven_cost_usd_p_kw, results
631
708
 
632
709
  except Exception as e:
633
710
  raise ValueError("Root finding failed.") from e
634
- elif kwargs["method"] == "brentq":
711
+
712
+ if method is Optimization.BRENTQ:
635
713
  try:
636
714
  # required args for 'brentq'
637
715
  if not pre_calc_bounds_and_tolerances:
@@ -660,13 +738,13 @@ def find_breakeven(
660
738
  disp=disp,
661
739
  )
662
740
 
663
- results = loan.Outputs.export()
664
- return breakeven_cost_usd_p_kw, {k: results.get(k) for k in pysam_outputs}
741
+ results = {k: getattr(loan.Outputs, k) for k in pysam_outputs}
742
+ return breakeven_cost_usd_p_kw, results
665
743
 
666
744
  except Exception as e:
667
745
  raise ValueError("Root finding failed.") from e
668
746
 
669
- elif kwargs["method"] == "newton":
747
+ if method is Optimization.NEWTON:
670
748
  try:
671
749
  # required args for 'newton'
672
750
  x0 = kwargs["x0"]
@@ -700,23 +778,57 @@ def find_breakeven(
700
778
  disp=disp,
701
779
  )
702
780
 
703
- results = loan.Outputs.export()
704
- return breakeven_cost_usd_p_kw, {k: results.get(k) for k in pysam_outputs}
781
+ results = {k: getattr(loan.Outputs, k) for k in pysam_outputs}
782
+ return breakeven_cost_usd_p_kw, results
705
783
 
706
784
  except Exception as e:
707
785
  raise ValueError("Root finding failed.") from e
708
786
 
709
- else:
710
- raise ValueError("Invalid method passed to find_breakeven function")
787
+ raise ValueError(f"Invalid `method` ({method}) passed to `find_breakeven` function")
711
788
 
712
789
 
713
790
  def find_breakeven_fom(
714
- row,
715
- financial,
716
- pysam_outputs,
717
- 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,
718
797
  **kwargs,
719
- ):
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
+
720
832
  # calculate theoretical min/max NPV values
721
833
  min_npv = calc_financial_performance_fom(1e9, row, financial)
722
834
 
@@ -758,7 +870,7 @@ def find_breakeven_fom(
758
870
  # capex value pre-calculated directly above
759
871
  tol = 1e-6 * ((a + b) / 2)
760
872
 
761
- if kwargs["method"] == "grid_search":
873
+ if method is Optimization.GRID_SEARCH:
762
874
  if kwargs["capex_array"] is None:
763
875
  capex_array = np.arange(5000.0, -500.0, -500.0)
764
876
  else:
@@ -776,7 +888,8 @@ def find_breakeven_fom(
776
888
 
777
889
  except Exception as e:
778
890
  raise ValueError("Grid search failed.") from e
779
- elif kwargs["method"] == "bisect":
891
+
892
+ if method is Optimization.BISECT:
780
893
  try:
781
894
  # required args for 'bisect
782
895
  if not pre_calc_bounds_and_tolerances:
@@ -805,12 +918,13 @@ def find_breakeven_fom(
805
918
  disp=disp,
806
919
  )
807
920
 
808
- results = financial.Outputs.export()
809
- return breakeven_cost_usd_p_kw, {k: results.get(k) for k in pysam_outputs}
921
+ results = {k: getattr(financial.Outputs, k) for k in pysam_outputs}
922
+ return breakeven_cost_usd_p_kw, results
810
923
 
811
924
  except Exception as e:
812
925
  raise ValueError("Root finding failed.") from e
813
- elif kwargs["method"] == "brentq":
926
+
927
+ if method is Optimization.BRENTQ:
814
928
  try:
815
929
  # required args for 'brentq'
816
930
  if not pre_calc_bounds_and_tolerances:
@@ -839,12 +953,13 @@ def find_breakeven_fom(
839
953
  disp=disp,
840
954
  )
841
955
 
842
- results = financial.Outputs.export()
843
- return breakeven_cost_usd_p_kw, {k: results.get(k) for k in pysam_outputs}
956
+ results = {k: getattr(financial.Outputs, k) for k in pysam_outputs}
957
+ return breakeven_cost_usd_p_kw, results
844
958
 
845
959
  except Exception as e:
846
960
  raise ValueError("Root finding failed.") from e
847
- elif kwargs["method"] == "newton":
961
+
962
+ if method is Optimization.NEWTON:
848
963
  try:
849
964
  # required args for 'newton'
850
965
  x0 = kwargs["x0"]
@@ -878,56 +993,46 @@ def find_breakeven_fom(
878
993
  disp=disp,
879
994
  )
880
995
 
881
- results = financial.Outputs.export()
882
- return breakeven_cost_usd_p_kw, {k: results.get(k) for k in pysam_outputs}
996
+ results = {k: getattr(financial.Outputs, k) for k in pysam_outputs}
997
+ return breakeven_cost_usd_p_kw, results
883
998
 
884
999
  except Exception as e:
885
1000
  raise ValueError("Root finding failed") from e
886
- else:
887
- raise ValueError("Invalid method passed to find_breakeven function")
1001
+
1002
+ raise ValueError("Invalid method passed to find_breakeven function")
888
1003
 
889
1004
 
890
1005
  def process_btm(
891
- row,
892
- tech,
893
- generation_hourly,
894
- consumption_hourly,
895
- 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
+ *,
896
1013
  en_batt=False,
897
- batt_dispatch=None,
898
1014
  ):
899
- """Behind-the-meter ...
900
-
901
- This function processes a BTM agent by:
902
- 1)
903
-
904
- Parameters
905
- ----------
906
- **row** : 'DataFrame row'
907
- The row of the dataframe on which the function is performed
908
- **exported_hourly** : ''
909
- 8760 of generation
910
- **consumption_hourly** : ''
911
- 8760 of consumption
912
- **tariff_dict** : 'dict'
913
- Dictionary containing tariff parameters
914
- **btm_nem** : 'bool'
915
- Enable NEM for BTM parcels
916
- **en_batt** : 'bool'
917
- Enable battery modeling
918
- **batt_dispatch** : 'string'
919
- Specify battery dispatch strategy type
1015
+ """Behind-the-meter calculation for a single agent.
920
1016
 
921
- Returns:
922
- -------
1017
+ TODO: list out the actual process
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.
923
1027
 
1028
+ Returns:
1029
+ pd.Series: Updated :py:attr:`row` with PySAM and breakeven cost results.
924
1030
  """
925
1031
  # extract agent load and generation profiles
926
1032
  generation_hourly = np.array(generation_hourly)
927
- consumption_hourly = np.array(consumption_hourly, dtype=np.float32)
928
1033
 
929
1034
  # specify tech-agnostic system size column
930
- row["system_size_kw"] = row[f"{tech}_size_kw_btm"]
1035
+ row["system_size_kw"] = row[f"{tech.value}_size_kw_btm"]
931
1036
 
932
1037
  # instantiate PySAM battery model based on agent sector
933
1038
  if row.loc["sector_abbr"] == "res":
@@ -1289,8 +1394,7 @@ def process_btm(
1289
1394
 
1290
1395
  _ = calc_financial_performance(row["system_capex_per_kw"], row, loan, batt_costs)
1291
1396
 
1292
- results = loan.Outputs.export()
1293
- row["additional_pysam_outputs"] = {k: results.get(k) for k in pysam_outputs}
1397
+ row["additional_pysam_outputs"] = {k: getattr(loan.Outputs, k) for k in pysam_outputs}
1294
1398
 
1295
1399
  # run root finding algorithm to find breakeven cost based on calculated NPV
1296
1400
  out, _ = find_breakeven(
@@ -1298,8 +1402,9 @@ def process_btm(
1298
1402
  loan=loan,
1299
1403
  pysam_outputs=pysam_outputs,
1300
1404
  batt_costs=batt_costs,
1405
+ method="newton",
1301
1406
  pre_calc_bounds_and_tolerances=False,
1302
- **{"method": "newton", "x0": 10000.0, "full_output": True},
1407
+ **{"x0": 10000.0, "full_output": True},
1303
1408
  )
1304
1409
 
1305
1410
  row["breakeven_cost_usd_p_kw"] = out
@@ -1308,40 +1413,39 @@ def process_btm(
1308
1413
 
1309
1414
 
1310
1415
  def process_fom(
1311
- row,
1312
- tech,
1313
- generation_hourly,
1314
- market_profile,
1315
- pysam_outputs,
1316
- en_batt=False,
1317
- 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,
1318
1424
  ):
1319
- """Front-of-the-meter ...
1425
+ """Front-of-meter calculation for a single agent.
1320
1426
 
1321
- This function processes a FOM agent by:
1322
- 1)
1427
+ TODO: list out the actual process
1323
1428
 
1324
- Parameters
1325
- ----------
1326
- **row** : 'DataFrame row'
1327
- The row of the dataframe on which the function is performed
1328
- **exported_hourly** : ''
1329
- 8760 of generation
1330
- **cambium_grid_value** : ''
1331
- 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.
1332
1437
 
1333
1438
  Returns:
1334
- -------
1335
-
1439
+ pd.Series: Updated :py:attr:`row` with PySAM and breakeven cost results.
1336
1440
  """
1337
1441
  # extract generation profile
1338
1442
  generation_hourly = np.array(generation_hourly)
1339
1443
 
1340
1444
  # specify tech-agnostic system size column
1341
- row["system_size_kw"] = row[f"{tech}_size_kw_fom"]
1445
+ row["system_size_kw"] = row[f"{tech.value}_size_kw_fom"]
1342
1446
 
1343
1447
  inv_eff = 1.0 # required inverter efficiency for FOM systems
1344
- gen = [i * inv_eff for i in generation_hourly]
1448
+ gen = (generation_hourly * inv_eff).tolist()
1345
1449
 
1346
1450
  # set up battery, with system generation conditional
1347
1451
  # on the battery generation being included
@@ -1350,15 +1454,15 @@ def process_fom(
1350
1454
  pass
1351
1455
  else:
1352
1456
  # initialize PySAM model
1353
- if tech == "solar":
1457
+ if tech is Technology.SOLAR:
1354
1458
  financial = mp.default("PVWattsMerchantPlant")
1355
- elif tech == "wind":
1459
+ elif tech is Technology.WIND:
1356
1460
  financial = mp.default("WindPowerMerchantPlant")
1357
1461
  else:
1358
1462
  msg = "Please write a wrapper to account for the new technology type"
1359
- raise NotImplementedError(f"{msg} {tech}")
1463
+ raise NotImplementedError(f"{msg} {tech.value}")
1360
1464
 
1361
- ptc_fed_amt = row[f"ptc_fed_amt_{tech}"]
1465
+ ptc_fed_amt = row[f"ptc_fed_amt_{tech.value}"]
1362
1466
  itc_fed_pct = row["itc_fed_pct"]
1363
1467
  deg = row["deg"]
1364
1468
  system_capex_per_kw = row["system_capex_per_kw"]
@@ -1426,10 +1530,12 @@ def process_fom(
1426
1530
  financial.Revenue.mp_enable_ancserv2 = 0
1427
1531
  financial.Revenue.mp_enable_ancserv3 = 0
1428
1532
  financial.Revenue.mp_enable_ancserv4 = 0
1429
- financial.Revenue.mp_ancserv1_revenue = [(0, 0) for i in range(len(market_profile))]
1430
- financial.Revenue.mp_ancserv2_revenue = [(0, 0) for i in range(len(market_profile))]
1431
- financial.Revenue.mp_ancserv3_revenue = [(0, 0) for i in range(len(market_profile))]
1432
- 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
1433
1539
 
1434
1540
  financial.CapacityPayments.cp_capacity_payment_type = 0
1435
1541
  financial.CapacityPayments.cp_capacity_payment_amount = [0]
@@ -1447,8 +1553,7 @@ def process_fom(
1447
1553
 
1448
1554
  log.info(f"row {row.loc['gid']} calculating financial performance")
1449
1555
  _ = calc_financial_performance_fom(system_capex_per_kw, row, financial)
1450
- results = financial.Outputs.export()
1451
- row["additional_pysam_outputs"] = {k: results.get(k) for k in pysam_outputs}
1556
+ row["additional_pysam_outputs"] = {k: getattr(financial.Outputs, k) for k in pysam_outputs}
1452
1557
 
1453
1558
  # run root finding algorithm to find breakeven cost based on calculated NPV
1454
1559
  log.info(f"row {row.loc['gid']} breakeven")
@@ -1465,46 +1570,70 @@ def process_fom(
1465
1570
  return row
1466
1571
 
1467
1572
 
1468
- 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
+ """
1469
1585
  try:
1470
1586
  results = {}
1471
1587
  for tech in config.project.settings.TECHS:
1472
- if tech == "wind":
1588
+ tech = Technology(tech)
1589
+ if tech is Technology.WIND:
1473
1590
  tech_config = row["turbine_class"]
1474
- elif tech == "solar":
1591
+ elif tech is Technology.SOLAR:
1475
1592
  # TODO: Not updated for the actual configuration, so solar is likely out of date
1476
- azimuth = config.rev.settings.azimuth_direction_to_degree[row[f"azimuth_{sector}"]]
1477
- tilt = row[f"tilt_{sector}"]
1478
- 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)
1479
1598
 
1480
- if row[f"{tech}_size_kw_{sector}"] > 0:
1599
+ if row[f"{tech.value}_size_kw_{sector.value}"] > 0:
1481
1600
  tech_config = row["turbine_class"]
1482
1601
  cf_hourly = find_cf_from_rev_wind(
1483
- config.rev.DIR,
1484
- config.project.settings.GENERATION_SCALE_OFFSET["wind"],
1485
- tech_config,
1486
- 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,
1487
1606
  )
1488
- generation_hourly = cf_hourly * row[f"{tech}_size_kw_{sector}"]
1607
+ generation_hourly = cf_hourly * row[f"{tech.value}_size_kw_{sector.value}"]
1489
1608
  generation_hourly = generation_hourly.round(2)
1490
1609
  generation_hourly = np.array(generation_hourly).astype(np.float32).round(2)
1491
1610
  else:
1492
1611
  # if system size is zero, return dummy values
1493
- results[f"{tech}_breakeven_cost_{sector}"] = -1
1494
- 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
+ }
1495
1616
  continue
1496
1617
 
1497
- 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
+
1498
1627
  row = process_btm(
1499
1628
  row=row,
1500
1629
  tech=tech,
1501
1630
  generation_hourly=generation_hourly,
1502
- consumption_hourly=row["consumption_hourly"],
1631
+ consumption_hourly=consumption_hourly,
1503
1632
  pysam_outputs=config.pysam.outputs.btm,
1504
1633
  en_batt=False,
1505
1634
  batt_dispatch=None, # TODO: enable battery switch earlier
1506
1635
  )
1507
- else:
1636
+ elif sector is Sector.FOM:
1508
1637
  # fetch 8760 cambium values for FOM
1509
1638
  market_profile = fetch_cambium_values(
1510
1639
  row,
@@ -1524,10 +1653,10 @@ def worker(row: pd.Series, sector: str, config: Configuration):
1524
1653
  )
1525
1654
 
1526
1655
  # store results in dictionary
1527
- results[f"{tech}_breakeven_cost_{sector}"] = row["breakeven_cost_usd_p_kw"]
1656
+ results[f"{tech.value}_breakeven_cost_{sector.value}"] = row["breakeven_cost_usd_p_kw"]
1528
1657
  results |= row["additional_pysam_outputs"]
1529
1658
 
1530
- return (row["gid"], results)
1659
+ return {row["gid"]: results}
1531
1660
 
1532
1661
  except Exception as e:
1533
1662
  log.exception(e)