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/cli/utils.py ADDED
@@ -0,0 +1,166 @@
1
+ """Provides shared utilities among the CLI subcommand modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ import pandas as pd
9
+ from rich.style import Style
10
+ from rich.table import Table
11
+ from rich.console import Console
12
+
13
+ from dwind.config import Year
14
+
15
+
16
+ DWIND = Path("/projects/dwind/agents")
17
+
18
+ console = Console()
19
+
20
+
21
+ def year_callback(ctx: typer.Context, param: typer.CallbackParam, value: int):
22
+ """Typer helper to validate the year input.
23
+
24
+ Parameters
25
+ ----------
26
+ ctx : typer.Context
27
+ The Typer context.
28
+ param : typer.CallbackParam
29
+ The Typer parameter.
30
+ value : int
31
+ User input for the analysis year basis, must be one of 2022, 2024, or 2025.
32
+
33
+ Returns:
34
+ -------
35
+ int
36
+ The input :py:param:`value`, if it is a valid input.
37
+
38
+ Raises:
39
+ ------
40
+ typer.BadParameter
41
+ Raised if the input is not one of 2022, 2024, or 2025.
42
+ """
43
+ if ctx.resilient_parsing:
44
+ return
45
+ try:
46
+ year = Year(value)
47
+ except ValueError as e:
48
+ raise typer.BadParameter(
49
+ f"Only {Year.values()} are valid options for `year`, not {value}."
50
+ ) from e
51
+ return year
52
+
53
+
54
+ def load_agents(
55
+ file_name: Path | None = None,
56
+ location: str | None = None,
57
+ sector: str | None = None,
58
+ model_config: str | Path | None = None,
59
+ *,
60
+ prepare: bool = False,
61
+ ) -> pd.DataFrame:
62
+ """Load the agent file based on a filename or the location and sector to a Pandas DataFrame,
63
+ and return the data frame.
64
+
65
+ Args:
66
+ file_name (Path | None, optional): Name of the agent file, if not auto-generating from
67
+ the :py:attr:`location` and :py:attr:`sector` inputs. Defaults to None.
68
+ location (str | None, optional): The name of the location or grouping, such as
69
+ "colorado_larimer" or "priority1". Defaults to None.
70
+ sector (str | None, optional): The name of the section. Must be one of "btm" or "fom".
71
+ Defaults to None.
72
+ model_config: (str | Path, optional): The file name and path for the overarching model
73
+ configuration containing SQL and file references, scenario configurations, and etc.
74
+ Defaults to None.
75
+ prepare (bool, optional): True if loading pre-chunked and prepared agent data, which should
76
+ bypass the standard column checking for additional joins, by default False.
77
+
78
+ Returns:
79
+ pd.DataFrame: The agent DataFrame.
80
+ """
81
+ from dwind.model import Agents
82
+
83
+ if file_name is None and (location is None or sector is None):
84
+ raise ValueError("One of `file_name` or `location` and `sector` must be provided.")
85
+
86
+ f_agents = (
87
+ file_name if file_name is not None else DWIND / f"{location}/agents_dwind_{sector}.parquet"
88
+ )
89
+ if not isinstance(f_agents, Path):
90
+ f_agents = Path(f_agents).resolve()
91
+
92
+ alternative_suffix = (".pqt", ".parquet", ".pkl", ".pickle", ".csv")
93
+ base_style = Style.parse("cyan")
94
+ if not f_agents.exists():
95
+ for suffix in alternative_suffix:
96
+ if (new_fn := f_agents.with_suffix(suffix)).exists():
97
+ if new_fn != f_agents:
98
+ msg = (
99
+ f"Using alternative agent file: {new_fn}\n\t"
100
+ f"Requested agent file: {f_agents}"
101
+ )
102
+ console.print(msg, style=base_style)
103
+ f_agents = new_fn
104
+ break
105
+
106
+ if prepare:
107
+ return Agents.load_and_prepare_agents(
108
+ agent_file=f_agents, sector=sector, model_config=model_config
109
+ )
110
+ return Agents.load_agents(agent_file=f_agents)
111
+
112
+
113
+ def print_status_table(
114
+ summary: pd.DataFrame,
115
+ id_col: str = "job",
116
+ chunk_col: str | None = None,
117
+ status_col: str = "status",
118
+ ) -> None:
119
+ """Prints a nicely formatted pandas DataFrame to the console.
120
+
121
+ Args:
122
+ summary (pd.DataFrame): Summary DataFrame of the job (:py:attr:`id_col`), chunk
123
+ (:py:attr:`chunk_col`), and status (:py:attr:`status_col`).
124
+ id_col (str): Name of the job ID column.
125
+ chunk_col (str): Name of the chunk index column.
126
+ status_col (str): Name of the final job status column.
127
+ """
128
+ table = Table()
129
+ table.add_column("Job ID")
130
+ if chunk_col is not None:
131
+ table.add_column("Agent Chunk")
132
+ table.add_column("Status")
133
+
134
+ if chunk_col is None:
135
+ cols = [id_col, status_col]
136
+ secondary_sort = status_col
137
+ else:
138
+ cols = [id_col, chunk_col, status_col]
139
+ secondary_sort = chunk_col
140
+ summary = summary[cols].astype({c: str for c in cols})
141
+ for _, row in summary.sort_values([status_col, secondary_sort]).iterrows():
142
+ table.add_row(*row.tolist())
143
+
144
+ console.print(table)
145
+
146
+
147
+ def cleanup_chunks(dir_out: str | None, which: str) -> None:
148
+ """Deletes the temporary agent chunk files, typically in
149
+ ``/path/to/analysis_scenario/chunk_files/agents``, and of the form ``agents_{ix}.pqt``.
150
+
151
+ Args:
152
+ dir_out (str | None): The same :py:attr:`dir_out` passed to the run command. The
153
+ ``chunk_files/agent_chunks`` will be automatically added to the path.
154
+ which (str): One of "agents" or "results" to indicate which chunked files should be deleted.
155
+ """
156
+ dir_out = Path.cwd() if dir_out is None else Path(dir_out).resolve()
157
+ out_path = dir_out / "chunk_files"
158
+ if which == "agents":
159
+ out_path = out_path / "agent_chunks"
160
+ elif which != "results":
161
+ raise ValueError("`which` must be one 'agents' or 'results'.")
162
+
163
+ for f in out_path.iterdir():
164
+ if f.suffix == ".pqt":
165
+ f.unlink()
166
+ print(f"Removed: {f}")
dwind/config.py CHANGED
@@ -1,11 +1,13 @@
1
1
  """Custom configuration data class to allow for dictionary style and dot notation calling of
2
- attributes.
2
+ attributes and Enums for validating configuration data.
3
3
  """
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
7
  import re
8
8
  import sys
9
+ from enum import Enum, IntEnum
10
+ from typing import Any, Annotated
9
11
  from pathlib import Path
10
12
 
11
13
 
@@ -17,54 +19,193 @@ else:
17
19
  # fmt: on
18
20
 
19
21
 
22
+ class ValuesMixin:
23
+ """Mixin class providing `.values()` for Enum classes."""
24
+
25
+ @staticmethod
26
+ def values() -> list[str]:
27
+ """Generate a list of the valid input strings."""
28
+ return [*Sector._value2member_map_]
29
+
30
+
31
+ class Sector(str, ValuesMixin, Enum):
32
+ """Enum validator for sector inputs."""
33
+
34
+ FOM: Annotated[str, "Front-of-meter"] = "fom"
35
+ BTM: Annotated[str, "Behind-the-meter"] = "btm"
36
+
37
+
38
+ class Technology(str, ValuesMixin, Enum):
39
+ """Enum validator for technology inputs."""
40
+
41
+ WIND: Annotated[str, "Wind generation model"] = "wind"
42
+ SOLAR: Annotated[str, "Solar generation model"] = "solar"
43
+
44
+
45
+ class Scenario(str, ValuesMixin, Enum):
46
+ """Enum validator for the scenario to run."""
47
+
48
+ BASELINE: Annotated[str, "Standard scenario to compare alternatives."] = "baseline"
49
+ METERING: Annotated[str, "TODO."] = "metering"
50
+ BILLING: Annotated[str, "TODO."] = "billing"
51
+ HIGHRECOST: Annotated[str, "High renewable adoption"] = "highrecost"
52
+ RE100: Annotated[str, "100% renewable adoption"] = "highrecost"
53
+ LOWRECOST: Annotated[str, "Low renewable adoption"] = "lowrecost"
54
+
55
+
56
+ class Year(ValuesMixin, IntEnum):
57
+ """Enum validator for analysis year."""
58
+
59
+ _2022 = 2022
60
+ _2025 = 2025
61
+ _2035 = 2035
62
+ _2040 = 2040
63
+
64
+
65
+ class Optimization(str, ValuesMixin, Enum):
66
+ """Enum validator for breakeven cost optimization strategies."""
67
+
68
+ BISECT = "bisect"
69
+ BRENTQ = "brentq"
70
+ GRID_SEARCH = "grid_search"
71
+ NEWTON = "newton"
72
+
73
+
74
+ class CRBModel(Enum):
75
+ """Convert between integers and "crb_model" data for efficient storage and retrieval."""
76
+
77
+ full_service_restaurant = 0
78
+ hospital = 1
79
+ large_hotel = 2
80
+ large_office = 3
81
+ medium_office = 4
82
+ midrise_apartment = 5
83
+ out_patient = 6
84
+ primary_school = 7
85
+ quick_service_restaurant = 8
86
+ reference = 9
87
+ secondary_school = 10
88
+ small_hotel = 11
89
+ small_office = 12
90
+ stand_alone_retail = 13
91
+ strip_mall = 14
92
+ supermarket = 15
93
+ warehouse = 16
94
+
95
+ @staticmethod
96
+ def model_map() -> dict[str, int]:
97
+ """Create a dictionary of name: int values for each crb model."""
98
+ return {el.name: el.value for el in CRBModel}
99
+
100
+ @staticmethod
101
+ def str_model_map() -> dict[str, str]:
102
+ """Create a dictionary of name: str(int) values for each crb model."""
103
+ return {el.name: str(el.value) for el in CRBModel}
104
+
105
+ @staticmethod
106
+ def int_map() -> dict[str, int]:
107
+ """Create a dictionary of int: name for each crb model."""
108
+ return {el.value: el.name for el in CRBModel}
109
+
110
+
20
111
  class Mapping(dict):
21
112
  """Dict-like class that allows for the use of dictionary style attribute calls on class
22
113
  attributes.
23
114
  """
24
115
 
25
- def __setitem__(self, key, item):
116
+ def __setitem__(self, key: Any, item: Any):
117
+ """Creates a new key, value pair in :py:attr:`__dict__`.
118
+
119
+ Args:
120
+ key (Any): A hashable dictionary key.
121
+ item (Any): A value to be retrieved when the :py:attr:`key` is called.
122
+ """
26
123
  self.__dict__[key] = item
27
124
 
28
- def __getitem__(self, key):
125
+ def __getitem__(self, key: Any) -> Any:
126
+ """Retrieve :py:attr:`key`'s value from :py:attr:`__dict__`.
127
+
128
+ Args:
129
+ key (Any): An existing key in :py:attr:`__dict__`.
130
+
131
+ Returns:
132
+ Any: The value paired to :py:attr:`key`.
133
+ """
29
134
  return self.__dict__[key]
30
135
 
31
136
  def __repr__(self):
137
+ """Returns the ``repr(self.__dict__)``."""
32
138
  return repr(self.__dict__)
33
139
 
34
- def __len__(self):
140
+ def __len__(self) -> int:
141
+ """Returns the number of keys in :py:attr:`__dict__`.
142
+
143
+ Returns:
144
+ int: The number of keys in :py:attr:`__dict__`.
145
+ """
35
146
  return len(self.__dict__)
36
147
 
37
- def __delitem__(self, key):
148
+ def __delitem__(self, key: Any):
149
+ """Delete's :py:attr:`key` from :py:attr:`__dict__`."""
38
150
  del self.__dict__[key]
39
151
 
40
152
  def clear(self):
153
+ """Deletes all entries in :py:attr:`__dict__`."""
41
154
  return self.__dict__.clear()
42
155
 
43
- def copy(self):
156
+ def copy(self) -> dict:
157
+ """Returns an unlinked copy of :py:attr:`__dict__`."""
44
158
  return self.__dict__.copy()
45
159
 
46
160
  def update(self, *args, **kwargs):
161
+ """Updates the provided args and keyword arguments of :py:attr:`__dict__`.
162
+
163
+ Args:
164
+ *args: Variable length argument list.
165
+ **kwargs: Arbitrary keyword arguments.
166
+ """
47
167
  return self.__dict__.update(*args, **kwargs)
48
168
 
49
169
  def keys(self):
170
+ """Returns the keys of :py:attr:`__dict__`."""
50
171
  return self.__dict__.keys()
51
172
 
52
173
  def values(self):
174
+ """Returns the :py:attr:`__dict__` values."""
53
175
  return self.__dict__.values()
54
176
 
55
177
  def items(self):
178
+ """Returns the keys and values of :py:attr:`__dict__`."""
56
179
  return self.__dict__.items()
57
180
 
58
181
  def pop(self, *args):
182
+ """Removes and returns the desired argments from :py:attr:`__dict__` if they exist.
183
+
184
+ Args:
185
+ *args: Variable length argument list.
186
+
187
+ Returns:
188
+ Any: values of :py:attr:`__dict__` from keys :py:attr:`*args`.
189
+ """
59
190
  return self.__dict__.pop(*args)
60
191
 
61
192
  def __cmp__(self, dict_):
193
+ """Compares :py:attr:`_dict` to :py:attr:`__dict__`.
194
+
195
+ Args:
196
+ dict_ (dict): Dictionary for object comparison.
197
+
198
+ Returns:
199
+ bool: Result of the comparison between :py:attr:`_dict` and :py:attr:`__dict__`.
200
+ """
62
201
  return self.__cmp__(self.__dict__, dict_)
63
202
 
64
203
  def __contains__(self, item):
204
+ """Checks if :py:attr:`item` is in :py:attr:`__dict__`."""
65
205
  return item in self.__dict__
66
206
 
67
207
  def __iter__(self):
208
+ """Custom iterator return the :py:attr:`__dict__`."""
68
209
  return iter(self.__dict__)
69
210
 
70
211
 
dwind/main.py ADDED
@@ -0,0 +1,20 @@
1
+ """Enables running dwind as a CLI tool, the primary interface for working with dwind."""
2
+
3
+ import typer
4
+
5
+ from dwind.cli import run, debug, collect
6
+
7
+
8
+ app = typer.Typer()
9
+
10
+ app.add_typer(
11
+ run.app, name="run", help="Run a dwind analysis via sbatch or in an interactive session."
12
+ )
13
+
14
+ app.add_typer(debug.app, name="debug", help="Help identify issues with analyses that were run.")
15
+
16
+ app.add_typer(collect.app, name="collect", help="Gather results from a run or rerun analysis.")
17
+
18
+
19
+ if __name__ == "__main__":
20
+ app()