engforge 0.3.0__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.
- engforge/__init__.py +26 -0
- engforge/_testing_components.py +864 -0
- engforge/analysis.py +257 -0
- engforge/attr_dynamics.py +222 -0
- engforge/attr_plotting.py +811 -0
- engforge/attr_signals.py +116 -0
- engforge/attr_slots.py +211 -0
- engforge/attr_solver.py +443 -0
- engforge/attributes.py +326 -0
- engforge/common.py +76 -0
- engforge/component_collections.py +276 -0
- engforge/components.py +55 -0
- engforge/configuration.py +673 -0
- engforge/dataframe.py +185 -0
- engforge/datastores/__init__.py +116 -0
- engforge/datastores/data.py +519 -0
- engforge/datastores/gdocs.py +2650 -0
- engforge/datastores/reporting.py +935 -0
- engforge/datastores/secrets.py +549 -0
- engforge/dynamics.py +662 -0
- engforge/eng/__init__.py +0 -0
- engforge/eng/costs.py +1459 -0
- engforge/eng/fluid_material.py +279 -0
- engforge/eng/geometry.py +1060 -0
- engforge/eng/pipes.py +1211 -0
- engforge/eng/prediction.py +366 -0
- engforge/eng/solid_materials.py +445 -0
- engforge/eng/structure.py +2228 -0
- engforge/eng/structure_beams.py +821 -0
- engforge/eng/thermodynamics.py +271 -0
- engforge/engforge_attributes.py +380 -0
- engforge/env_var.py +214 -0
- engforge/locations.py +18 -0
- engforge/logging.py +290 -0
- engforge/patterns.py +449 -0
- engforge/problem_context.py +2251 -0
- engforge/properties.py +501 -0
- engforge/reporting.py +595 -0
- engforge/solveable.py +964 -0
- engforge/solver.py +316 -0
- engforge/solver_utils.py +726 -0
- engforge/system.py +143 -0
- engforge/system_reference.py +299 -0
- engforge/tabulation.py +288 -0
- engforge/test/__init__.py +2 -0
- engforge/test/_pre_test_structures.py +221 -0
- engforge/test/report_testing.py +158 -0
- engforge/test/test_airfilter.py +91 -0
- engforge/test/test_analysis.py +3 -0
- engforge/test/test_comp_iter.py +196 -0
- engforge/test/test_composition.py +139 -0
- engforge/test/test_costs.py +338 -0
- engforge/test/test_dynamics.py +109 -0
- engforge/test/test_dynamics_spaces.py +39 -0
- engforge/test/test_four_bar.py +97 -0
- engforge/test/test_modules.py +72 -0
- engforge/test/test_performance.py +93 -0
- engforge/test/test_pipes.py +49 -0
- engforge/test/test_problem.py +411 -0
- engforge/test/test_problem_deepscoping.py +68 -0
- engforge/test/test_slider_crank.py +51 -0
- engforge/test/test_solver.py +343 -0
- engforge/test/test_tabulation.py +233 -0
- engforge/typing.py +92 -0
- engforge-0.3.0.dist-info/METADATA +392 -0
- engforge-0.3.0.dist-info/RECORD +69 -0
- engforge-0.3.0.dist-info/WHEEL +5 -0
- engforge-0.3.0.dist-info/licenses/LICENSE +7 -0
- engforge-0.3.0.dist-info/top_level.txt +1 -0
engforge/eng/costs.py
ADDED
|
@@ -0,0 +1,1459 @@
|
|
|
1
|
+
"""Defines a CostModel & Economics Component that define & orchestrate cost accounting respectively.
|
|
2
|
+
|
|
3
|
+
CostModels can have a `cost_per_item` and additionally calculate a `cumulative_cost` from internally defined `CostModel`s.
|
|
4
|
+
|
|
5
|
+
CostModel's can have cost_property's which detail how and when a cost should be applied & grouped. By default each CostModel has a `cost_per_item` which is reflected in `item_cost` cost_property set on the `initial` term as a `unit` category. Multiple categories of cost are also able to be set on cost_properties as follows
|
|
6
|
+
|
|
7
|
+
Cost Models can represent multiple instances of a component, and can be set to have a `num_items` multiplier to account for multiple instances of a component. CostModels can have a `term_length` which will apply costs over the term, using the `cost_property.mode` to determine at which terms a cost should be applied.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
@forge
|
|
11
|
+
class Widget(Component,CostModel):
|
|
12
|
+
|
|
13
|
+
#use num_items as a multiplier for costs, `cost_properties` can have their own custom num_items value.
|
|
14
|
+
num_items:float = 100
|
|
15
|
+
|
|
16
|
+
@cost_property(mode='initial',category='capex,manufacturing',num_items=1)
|
|
17
|
+
def cost_of_XYZ(self) -> float:
|
|
18
|
+
return cost
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Economics models sum CostModel.cost_properties recursively on the parent they are defined. Economics computes the grouped category costs for each item recursively as well as summary properties like annualized values and levelized cost. Economic output is determined by a `fixed_output` or overriding `calculate_production(self,parent)` to dynamically calculate changing economics based on factors in the parent.
|
|
23
|
+
|
|
24
|
+
Default costs can be set on any CostModel.Slot attribute, by using default_cost(<slot_name>,<cost>) on the class, this will provide a default cost for the slot if no cost is set on the instance. Custom costs can be set on the instance with custom_cost(<slot_name>,<cost>). If cost is a CostModel, it will be assigned to the slot if it is not already assigned.
|
|
25
|
+
|
|
26
|
+
The economics term_length applies costs over the term, using the `cost_property.mode` to determine at which terms a cost should be applied.
|
|
27
|
+
|
|
28
|
+
@forge
|
|
29
|
+
class Parent(System,CostModel)
|
|
30
|
+
|
|
31
|
+
econ = Slot.define(Economics) #will calculate parent costs as well
|
|
32
|
+
cost = Slot.define(Widget) #slots automatically set to none if no input provided
|
|
33
|
+
|
|
34
|
+
Parent(econ=Economics(term_length=25,discount_rate=0.05,fixed_output=1000))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from engforge.components import Component
|
|
40
|
+
from engforge.configuration import forge, Configuration
|
|
41
|
+
from engforge.tabulation import TabulationMixin, system_property
|
|
42
|
+
from engforge.system_reference import Ref
|
|
43
|
+
from engforge.properties import (
|
|
44
|
+
instance_cached,
|
|
45
|
+
solver_cached,
|
|
46
|
+
cached_system_property,
|
|
47
|
+
)
|
|
48
|
+
from engforge.logging import LoggingMixin
|
|
49
|
+
from engforge.component_collections import ComponentIter
|
|
50
|
+
import typing
|
|
51
|
+
import attrs
|
|
52
|
+
import uuid
|
|
53
|
+
import numpy
|
|
54
|
+
import collections
|
|
55
|
+
import pandas
|
|
56
|
+
import collections
|
|
57
|
+
import re
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CostLog(LoggingMixin):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
log = CostLog()
|
|
65
|
+
|
|
66
|
+
# Cost Term Modes are a quick lookup for cost term support
|
|
67
|
+
global COST_TERM_MODES, COST_CATEGORIES
|
|
68
|
+
COST_TERM_MODES = {
|
|
69
|
+
"initial": lambda inst, term, econ: True if term < 1 else False,
|
|
70
|
+
"maintenance": lambda inst, term, econ: True if term >= 1 else False,
|
|
71
|
+
"always": lambda inst, term, econ: True,
|
|
72
|
+
"end": lambda inst, term, econ: (
|
|
73
|
+
True if hasattr(econ, "term_length") and term == econ.term_length - 1 else False
|
|
74
|
+
),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
category_type = typing.Union[str, list]
|
|
78
|
+
COST_CATEGORIES = set(("misc",))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_num_from_cost_prop(ref):
|
|
82
|
+
"""analyzes the reference and returns the number of items"""
|
|
83
|
+
if isinstance(ref, (float, int)):
|
|
84
|
+
return ref
|
|
85
|
+
co = getattr(ref.comp.__class__, ref.key, None)
|
|
86
|
+
return co.get_num_items(ref.comp)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class cost_property(system_property):
|
|
90
|
+
"""A thin wrapper over `system_property` that will be accounted by `Economics` Components and apply term & categorization
|
|
91
|
+
|
|
92
|
+
`cost_property` should return a float/int always and will raise an error if the return annotation is different, although annotations are not required and will default to float.
|
|
93
|
+
|
|
94
|
+
#Terms:
|
|
95
|
+
Terms start counting at 0 and can be evald by the Economic.term_length
|
|
96
|
+
cost_properties will return their value as system_properties do without regard for the term state, however a CostModel's costs at a term can be retrived by `costs_at_term`. The default mode is for `initial` cost
|
|
97
|
+
|
|
98
|
+
#Categories:
|
|
99
|
+
Categories are a way to report cost categories and multiple can be applied to a cost. Categories are grouped by the Economics system at reported in bulk by term and over the term_length
|
|
100
|
+
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
valild_types = (int, float)
|
|
104
|
+
cost_categories: list = None
|
|
105
|
+
term_mode: str = None
|
|
106
|
+
num_items: int = None
|
|
107
|
+
|
|
108
|
+
_all_modes: dict = COST_TERM_MODES
|
|
109
|
+
_all_categories: set = COST_CATEGORIES
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
fget=None,
|
|
114
|
+
fset=None,
|
|
115
|
+
fdel=None,
|
|
116
|
+
doc=None,
|
|
117
|
+
desc=None,
|
|
118
|
+
label=None,
|
|
119
|
+
stochastic=False,
|
|
120
|
+
mode: str = "initial",
|
|
121
|
+
category: category_type = None,
|
|
122
|
+
num_items: int = None,
|
|
123
|
+
):
|
|
124
|
+
"""extends system_property interface with mode & category keywords
|
|
125
|
+
:param mode: can be one of `initial`,`maintenance`,`always` or a function with signature f(inst,term,econ) as an integer and returning a boolean True if it is to be applied durring that term.
|
|
126
|
+
"""
|
|
127
|
+
super().__init__(fget, fset, fdel, doc, desc, label, stochastic)
|
|
128
|
+
|
|
129
|
+
self.valild_types = (int, str) # only numerics
|
|
130
|
+
if isinstance(mode, str):
|
|
131
|
+
mode = mode.lower()
|
|
132
|
+
assert (
|
|
133
|
+
mode in COST_TERM_MODES
|
|
134
|
+
), f"mode: {mode} is not in {set(COST_TERM_MODES.keys())}"
|
|
135
|
+
self.term_mode = mode
|
|
136
|
+
elif callable(mode):
|
|
137
|
+
fid = str(uuid.uuid4())
|
|
138
|
+
self.__class__._all_modes[fid] = mode
|
|
139
|
+
self.term_mode = fid
|
|
140
|
+
else:
|
|
141
|
+
raise ValueError(f"mode: {mode} must be cost term str or callable")
|
|
142
|
+
|
|
143
|
+
# cost categories
|
|
144
|
+
if category is not None:
|
|
145
|
+
if isinstance(category, str):
|
|
146
|
+
self.cost_categories = category.split(",")
|
|
147
|
+
elif isinstance(category, list):
|
|
148
|
+
self.cost_categories = category
|
|
149
|
+
else:
|
|
150
|
+
raise ValueError(f"categories: {category} not string or list")
|
|
151
|
+
for cc in self.cost_categories:
|
|
152
|
+
self.__class__._all_categories.add(cc)
|
|
153
|
+
else:
|
|
154
|
+
self.cost_categories = ["misc"]
|
|
155
|
+
|
|
156
|
+
# number of items override
|
|
157
|
+
if num_items is not None:
|
|
158
|
+
self.num_items = num_items
|
|
159
|
+
|
|
160
|
+
def apply_at_term(self, inst, term, econ=None):
|
|
161
|
+
if term < 0:
|
|
162
|
+
raise ValueError(f"negative term!")
|
|
163
|
+
if self.__class__._all_modes[self.term_mode](inst, term, econ):
|
|
164
|
+
return True
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
def get_func_return(self, func):
|
|
168
|
+
"""ensures that the function has a return annotation, and that return annotation is in valid sort types"""
|
|
169
|
+
anno = func.__annotations__
|
|
170
|
+
typ = anno.get("return", None)
|
|
171
|
+
if typ is not None and not typ in (int, float):
|
|
172
|
+
raise Exception(
|
|
173
|
+
f"system_property input: function {func.__name__} must have valid return annotation of type: {(int,float)}"
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
self.return_type = float
|
|
177
|
+
|
|
178
|
+
def get_num_items(self, obj):
|
|
179
|
+
"""applies the num_items override or the costmodel default if not set"""
|
|
180
|
+
if self.num_items is not None:
|
|
181
|
+
k = self.num_items
|
|
182
|
+
else:
|
|
183
|
+
k = obj.num_items if isinstance(obj, CostModel) else 1
|
|
184
|
+
return k
|
|
185
|
+
|
|
186
|
+
def __get__(self, obj, objtype=None):
|
|
187
|
+
if obj is None:
|
|
188
|
+
return self # class support
|
|
189
|
+
if self.fget is None:
|
|
190
|
+
raise AttributeError("unreadable attribute")
|
|
191
|
+
|
|
192
|
+
# apply the costmodel with the item multiplier
|
|
193
|
+
k = self.get_num_items(obj)
|
|
194
|
+
return k * self.fget(obj)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@forge
|
|
198
|
+
class CostModel(Configuration, TabulationMixin):
|
|
199
|
+
"""CostModel is a mixin for components or systems that reports its costs through the `cost` system property, which by default sums the `item_cost` and `sub_items_cost`.
|
|
200
|
+
|
|
201
|
+
`item_cost` is determined by `calculate_item_cost()` which by default uses: `cost_per_item` field to return the item cost, which defaults to `numpy.nan` if not set. Nan values are ignored and replaced with 0.
|
|
202
|
+
|
|
203
|
+
`sub_items_cost` system_property summarizes the costs of any component in a Slot that has a `CostModel` or for SlotS which CostModel.declare_cost(`slot`,default=numeric|CostModelInst|dict[str,float])
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
# TODO: remove "default costs" concept and just use cost_properties since thats declarative and doesn't create a "phantom" representation to maintain
|
|
207
|
+
# TODO: it might be a good idea to add a "castable" namespace for components so they can all reference a common dictionary. Maybe all problem variables are merged into a single namespace to solve issues of "duplication"
|
|
208
|
+
_slot_costs: dict # TODO: insantiate per class
|
|
209
|
+
|
|
210
|
+
# basic attribute interface returns the item cost as `N x cost_per_item``
|
|
211
|
+
cost_per_item: float = attrs.field(default=numpy.nan)
|
|
212
|
+
num_items: int = attrs.field(default=1) # set to 0 to disable the item cost
|
|
213
|
+
|
|
214
|
+
def __on_init__(self):
|
|
215
|
+
self.set_default_costs()
|
|
216
|
+
self.debug(f"setting default costs {self._slot_costs}")
|
|
217
|
+
|
|
218
|
+
def update_dflt_costs(self):
|
|
219
|
+
"""updates internal default slot costs if the current component doesn't exist or isn't a cost model, this is really a component method but we will use it never the less.
|
|
220
|
+
|
|
221
|
+
This should be called from Component.update() if default costs are used
|
|
222
|
+
"""
|
|
223
|
+
if self._slot_costs:
|
|
224
|
+
current_comps = self.internal_components()
|
|
225
|
+
for k, v in self._slot_costs.items():
|
|
226
|
+
# Check if the cost model will be accessed
|
|
227
|
+
no_comp = k not in current_comps
|
|
228
|
+
is_cost = not no_comp and isinstance(current_comps[k], CostModel)
|
|
229
|
+
dflt_is_cost_comp = all(
|
|
230
|
+
[isinstance(v, CostModel), isinstance(v, Component)]
|
|
231
|
+
)
|
|
232
|
+
if no_comp and not is_cost and dflt_is_cost_comp:
|
|
233
|
+
self.debug("Updating default {k}")
|
|
234
|
+
v.update(self)
|
|
235
|
+
|
|
236
|
+
def set_default_costs(self):
|
|
237
|
+
"""set default costs if no costs are set"""
|
|
238
|
+
inter_config = self.internal_configurations()
|
|
239
|
+
for k, dflt in self._slot_costs.items():
|
|
240
|
+
if k not in inter_config and isinstance(dflt, CostModel):
|
|
241
|
+
setattr(self, k, attrs.evolve(dflt, parent=self))
|
|
242
|
+
elif (
|
|
243
|
+
k not in inter_config
|
|
244
|
+
and isinstance(dflt, type)
|
|
245
|
+
and issubclass(dflt, CostModel)
|
|
246
|
+
):
|
|
247
|
+
self.warning(
|
|
248
|
+
f"setting default cost {k} from costmodel class, provide a default instance instead!"
|
|
249
|
+
)
|
|
250
|
+
setattr(self, k, dflt())
|
|
251
|
+
|
|
252
|
+
# Reset cache
|
|
253
|
+
self.internal_components(True)
|
|
254
|
+
|
|
255
|
+
@classmethod
|
|
256
|
+
def subcls_compile(cls):
|
|
257
|
+
assert not issubclass(cls, ComponentIter), "component iter not supported"
|
|
258
|
+
log.debug(f"compiling costs {cls}")
|
|
259
|
+
cls.reset_cls_costs()
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def reset_cls_costs(cls):
|
|
263
|
+
cls._slot_costs = {}
|
|
264
|
+
|
|
265
|
+
@classmethod
|
|
266
|
+
def default_cost(
|
|
267
|
+
cls,
|
|
268
|
+
slot_name: str,
|
|
269
|
+
cost: typing.Union[float, "CostModel"],
|
|
270
|
+
warn_on_non_costmodel=True,
|
|
271
|
+
):
|
|
272
|
+
"""Provide a default cost for Slot items that are not CostModel's. Cost is applied class wide, but can be overriden with custom_cost per instance"""
|
|
273
|
+
assert not isinstance(
|
|
274
|
+
cost, type
|
|
275
|
+
), f"insantiate classes before adding as a cost!"
|
|
276
|
+
assert slot_name in cls.slots_attributes(), f"slot {slot_name} doesnt exist"
|
|
277
|
+
assert isinstance(cost, (float, int, dict)) or isinstance(
|
|
278
|
+
cost, CostModel
|
|
279
|
+
), "only numeric types or CostModel instances supported"
|
|
280
|
+
|
|
281
|
+
atrb = cls.slots_attributes(attr_type=True)[slot_name]
|
|
282
|
+
atypes = atrb.accepted
|
|
283
|
+
if warn_on_non_costmodel and not any(
|
|
284
|
+
[issubclass(at, CostModel) for at in atypes]
|
|
285
|
+
):
|
|
286
|
+
log.warning(f"assigning cost to non CostModel based slot {slot_name}")
|
|
287
|
+
|
|
288
|
+
cls._slot_costs[slot_name] = cost
|
|
289
|
+
|
|
290
|
+
# IDEA: create slot if one doesn't exist, for dictionaries and assign a ComponentDict+CostModel in wide mode?
|
|
291
|
+
|
|
292
|
+
def custom_cost(
|
|
293
|
+
self,
|
|
294
|
+
slot_name: str,
|
|
295
|
+
cost: typing.Union[float, "CostModel"],
|
|
296
|
+
warn_on_non_costmodel=True,
|
|
297
|
+
):
|
|
298
|
+
"""Takes class costs set, and creates a copy of the class costs, then applies the cost numeric or CostMethod in the same way but only for that instance of"""
|
|
299
|
+
assert not isinstance(
|
|
300
|
+
cost, type
|
|
301
|
+
), f"insantiate classes before adding as a cost!"
|
|
302
|
+
assert slot_name in self.slots_attributes(), f"slot {slot_name} doesnt exist"
|
|
303
|
+
assert isinstance(cost, (float, int, dict)) or isinstance(
|
|
304
|
+
cost, CostModel
|
|
305
|
+
), "only numeric types or CostModel instances supported"
|
|
306
|
+
|
|
307
|
+
atrb = self.__class__.slots_attributes(attr_type=True)[slot_name]
|
|
308
|
+
atypes = atrb.accepted
|
|
309
|
+
if warn_on_non_costmodel and not any(
|
|
310
|
+
[issubclass(at, CostModel) for at in atypes]
|
|
311
|
+
):
|
|
312
|
+
self.warning(f"assigning cost to non CostModel based slot {slot_name}")
|
|
313
|
+
|
|
314
|
+
# convert from classinfo
|
|
315
|
+
if self._slot_costs is self.__class__._slot_costs:
|
|
316
|
+
self._slot_costs = self.__class__._slot_costs.copy()
|
|
317
|
+
self._slot_costs[slot_name] = cost
|
|
318
|
+
self.set_default_costs()
|
|
319
|
+
|
|
320
|
+
# if the cost is a cost model, and there's nothing assigned to the slot, assign it
|
|
321
|
+
# if assign_when_missing and isinstance(cost,CostModel):
|
|
322
|
+
# if hasattr(self,slot_name) and getattr(self,slot_name) is None:
|
|
323
|
+
# self.info(f'assigning custom cost {slot_name} with {cost}')
|
|
324
|
+
# setattr(self,slot_name,cost)
|
|
325
|
+
# elif hasattr(self,slot_name):
|
|
326
|
+
# self.warning(f'could not assign custom cost to {slot_name} with {cost}, already assigned to {getattr(self,slot_name)}')
|
|
327
|
+
|
|
328
|
+
def calculate_item_cost(self) -> float:
|
|
329
|
+
"""override this with a parametric model related to this systems attributes and properties"""
|
|
330
|
+
return self.num_items * self.cost_per_item
|
|
331
|
+
|
|
332
|
+
@system_property
|
|
333
|
+
def sub_items_cost(self) -> float:
|
|
334
|
+
"""calculates the total cost of all sub-items, using the components CostModel if it is provided, and using the declared_cost as a backup"""
|
|
335
|
+
return self.sub_costs()
|
|
336
|
+
|
|
337
|
+
@cost_property(mode="initial", category="unit")
|
|
338
|
+
def item_cost(self) -> float:
|
|
339
|
+
calc_item = self.calculate_item_cost()
|
|
340
|
+
return numpy.nansum([0, calc_item])
|
|
341
|
+
|
|
342
|
+
@system_property
|
|
343
|
+
def combine_cost(self) -> float:
|
|
344
|
+
"""the sum of all cost properties"""
|
|
345
|
+
return self.sum_costs()
|
|
346
|
+
|
|
347
|
+
@system_property
|
|
348
|
+
def itemized_costs(self) -> float:
|
|
349
|
+
"""sums costs of cost_property's in this item that are present at term=0"""
|
|
350
|
+
initial_costs = self.costs_at_term(0)
|
|
351
|
+
return numpy.nansum(list(initial_costs.values()))
|
|
352
|
+
|
|
353
|
+
@system_property
|
|
354
|
+
def future_costs(self) -> float:
|
|
355
|
+
"""sums costs of cost_property's in this item that do not appear at term=0"""
|
|
356
|
+
initial_costs = self.costs_at_term(0, False)
|
|
357
|
+
return numpy.nansum(list(initial_costs.values()))
|
|
358
|
+
|
|
359
|
+
def sum_costs(self, saved: set = None, categories: tuple = None, term=0, econ=None):
|
|
360
|
+
"""sums costs of cost_property's in this item that are present at term=0, and by category if define as input"""
|
|
361
|
+
if saved is None:
|
|
362
|
+
saved = set((self,)) # item cost included!
|
|
363
|
+
elif self not in saved:
|
|
364
|
+
saved.add(self)
|
|
365
|
+
itemcst = list(
|
|
366
|
+
self.dict_itemized_costs(saved, categories, term, econ=econ).values()
|
|
367
|
+
)
|
|
368
|
+
csts = [self.sub_costs(saved, categories, term), numpy.nansum(itemcst)]
|
|
369
|
+
return numpy.nansum(csts)
|
|
370
|
+
|
|
371
|
+
def dict_itemized_costs(
|
|
372
|
+
self,
|
|
373
|
+
saved: set = None,
|
|
374
|
+
categories: tuple = None,
|
|
375
|
+
term=0,
|
|
376
|
+
test_val=True,
|
|
377
|
+
econ=None,
|
|
378
|
+
) -> dict:
|
|
379
|
+
ccp = self.class_cost_properties()
|
|
380
|
+
costs = {
|
|
381
|
+
k: (
|
|
382
|
+
obj.__get__(self)
|
|
383
|
+
if obj.apply_at_term(self, term, econ) == test_val
|
|
384
|
+
else 0
|
|
385
|
+
)
|
|
386
|
+
for k, obj in ccp.items()
|
|
387
|
+
if categories is None
|
|
388
|
+
or any([cc in categories for cc in obj.cost_categories])
|
|
389
|
+
}
|
|
390
|
+
return costs
|
|
391
|
+
|
|
392
|
+
def sub_costs(self, saved: set = None, categories: tuple = None, term=0, econ=None):
|
|
393
|
+
"""gets items from CostModel's defined in a Slot attribute or in a slot default, tolerrant to nan's in cost definitions"""
|
|
394
|
+
if saved is None:
|
|
395
|
+
saved = set()
|
|
396
|
+
|
|
397
|
+
sub_tot = 0
|
|
398
|
+
|
|
399
|
+
for slot in self.slots_attributes():
|
|
400
|
+
comp = getattr(self, slot)
|
|
401
|
+
|
|
402
|
+
if comp in saved:
|
|
403
|
+
# print(f'skipping {slot}:{comp}')
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
elif isinstance(comp, Configuration):
|
|
407
|
+
saved.add(comp)
|
|
408
|
+
|
|
409
|
+
if isinstance(comp, CostModel):
|
|
410
|
+
sub = comp.sum_costs(saved, categories, term, econ=econ)
|
|
411
|
+
log.debug(
|
|
412
|
+
f"{self.identity} adding: {comp.identity if comp else comp}: {sub}+{sub_tot}"
|
|
413
|
+
)
|
|
414
|
+
cst = [sub_tot, sub]
|
|
415
|
+
sub_tot = numpy.nansum(cst)
|
|
416
|
+
|
|
417
|
+
elif (
|
|
418
|
+
slot in self._slot_costs
|
|
419
|
+
and (categories is None or "unit" in categories)
|
|
420
|
+
and term == 0
|
|
421
|
+
):
|
|
422
|
+
# Add default costs from direct slots
|
|
423
|
+
dflt = self._slot_costs[slot]
|
|
424
|
+
sub = eval_slot_cost(dflt, saved)
|
|
425
|
+
log.debug(
|
|
426
|
+
f"sub: {self.identity} adding slot: {comp.identity if comp else comp}.{slot}: {sub}+{sub_tot}"
|
|
427
|
+
)
|
|
428
|
+
cst = [sub_tot, sub]
|
|
429
|
+
sub_tot = numpy.nansum(cst)
|
|
430
|
+
|
|
431
|
+
# add base class slot values when comp was nonee
|
|
432
|
+
if comp is None:
|
|
433
|
+
# print(f'skipping {slot}:{comp}')
|
|
434
|
+
comp_cls = self.slots_attributes(attr_type=True)[slot].accepted
|
|
435
|
+
for cc in comp_cls:
|
|
436
|
+
if issubclass(cc, CostModel):
|
|
437
|
+
if cc._slot_costs:
|
|
438
|
+
for k, v in cc._slot_costs.items():
|
|
439
|
+
sub = eval_slot_cost(v, saved)
|
|
440
|
+
log.debug(
|
|
441
|
+
f"sub: {self.identity} adding dflt: {slot}.{k}: {sub}+{sub_tot}"
|
|
442
|
+
)
|
|
443
|
+
cst = [sub_tot, sub]
|
|
444
|
+
sub_tot = numpy.nansum(cst)
|
|
445
|
+
break # only add once
|
|
446
|
+
|
|
447
|
+
return sub_tot
|
|
448
|
+
|
|
449
|
+
# Cost Term & Category Reporting
|
|
450
|
+
def costs_at_term(self, term: int, test_val=True, econ=None) -> dict:
|
|
451
|
+
"""returns a dictionary of all costs at term i, with zero if the mode
|
|
452
|
+
function returns False at that term
|
|
453
|
+
|
|
454
|
+
:param econ: the economics component to apply "end" term mode
|
|
455
|
+
"""
|
|
456
|
+
ccp = self.class_cost_properties()
|
|
457
|
+
return {
|
|
458
|
+
k: (
|
|
459
|
+
obj.__get__(self)
|
|
460
|
+
if obj.apply_at_term(self, term, econ) == test_val
|
|
461
|
+
else 0
|
|
462
|
+
)
|
|
463
|
+
for k, obj in ccp.items()
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
@classmethod
|
|
467
|
+
def class_cost_properties(cls) -> dict:
|
|
468
|
+
"""returns cost_property objects from this class & subclasses"""
|
|
469
|
+
return {
|
|
470
|
+
k: v
|
|
471
|
+
for k, v in cls.system_properties_classdef(True).items()
|
|
472
|
+
if isinstance(v, cost_property)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
@property
|
|
476
|
+
def cost_properties(self) -> dict:
|
|
477
|
+
"""returns the current values of the current properties"""
|
|
478
|
+
ccp = self.class_cost_properties()
|
|
479
|
+
return {k: obj.__get__(self) for k, obj in ccp.items()}
|
|
480
|
+
|
|
481
|
+
@property
|
|
482
|
+
def cost_categories(self):
|
|
483
|
+
"""returns itemized costs grouped by category"""
|
|
484
|
+
base = {cc: 0 for cc in self.all_categories()}
|
|
485
|
+
for k, obj in self.class_cost_properties().items():
|
|
486
|
+
for cc in obj.cost_categories:
|
|
487
|
+
base[cc] += obj.__get__(self)
|
|
488
|
+
return base
|
|
489
|
+
|
|
490
|
+
def cost_categories_at_term(self, term: int, econ=None):
|
|
491
|
+
base = {cc: 0 for cc in self.all_categories()}
|
|
492
|
+
for k, obj in self.class_cost_properties().items():
|
|
493
|
+
if obj.apply_at_term(self, term, econ):
|
|
494
|
+
for cc in obj.cost_categories:
|
|
495
|
+
base[cc] += obj.__get__(self)
|
|
496
|
+
return base
|
|
497
|
+
|
|
498
|
+
@classmethod
|
|
499
|
+
def all_categories(self):
|
|
500
|
+
return COST_CATEGORIES
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
cost_type = typing.Union[float, int, CostModel, dict]
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def eval_slot_cost(slot_item: cost_type, saved: set = None):
|
|
507
|
+
sub_tot = 0
|
|
508
|
+
log.debug(f"evaluating slot: {slot_item}")
|
|
509
|
+
if isinstance(slot_item, (float, int)):
|
|
510
|
+
sub_tot += numpy.nansum([slot_item, 0])
|
|
511
|
+
elif isinstance(slot_item, CostModel):
|
|
512
|
+
sub_tot += numpy.nansum([slot_item.sum_costs(saved), 0])
|
|
513
|
+
elif isinstance(slot_item, type) and issubclass(slot_item, CostModel):
|
|
514
|
+
log.warning(
|
|
515
|
+
f"slot {slot_item} has class CostModel, using its `item_cost` only, create an instance to fully model the cost"
|
|
516
|
+
)
|
|
517
|
+
sub_tot = numpy.nansum([sub_tot, slot_item.cost_per_item])
|
|
518
|
+
elif isinstance(slot_item, dict):
|
|
519
|
+
sub_tot += numpy.nansum(list(slot_item.values()))
|
|
520
|
+
return sub_tot
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def gend(deect: dict):
|
|
524
|
+
for k, v in deect.items():
|
|
525
|
+
if isinstance(v, dict):
|
|
526
|
+
for kk, v in gend(v):
|
|
527
|
+
yield f"{k}.{kk}", v
|
|
528
|
+
else:
|
|
529
|
+
yield k, v
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
parent_types = typing.Union[Component, "System"]
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# TODO: automatically apply economics at problem level if cost_model present, no need for parent econ lookups
|
|
536
|
+
@forge
|
|
537
|
+
class Economics(Component):
|
|
538
|
+
"""
|
|
539
|
+
Economics is a component that summarizes costs and reports the economics of a system and its components in a recursive format.
|
|
540
|
+
Attributes:
|
|
541
|
+
term_length (int): The length of the term for economic calculations. Default is 0.
|
|
542
|
+
discount_rate (float): The discount rate applied to economic calculations. Default is 0.0.
|
|
543
|
+
fixed_output (float): The fixed output value for the economic model. Default is numpy.nan.
|
|
544
|
+
output_type (str): The type of output for the economic model. Default is "generic".
|
|
545
|
+
terms_per_year (int): The number of terms per year for economic calculations. Default is 1.
|
|
546
|
+
_calc_output (float): Internal variable to store calculated output.
|
|
547
|
+
_costs (float): Internal variable to store calculated costs.
|
|
548
|
+
_cost_references (dict): Internal dictionary to store cost references.
|
|
549
|
+
_cost_categories (dict): Internal dictionary to store cost categories.
|
|
550
|
+
_comp_categories (dict): Internal dictionary to store component categories.
|
|
551
|
+
_comp_costs (dict): Internal dictionary to store component costs.
|
|
552
|
+
parent (parent_types): The parent component or system.
|
|
553
|
+
Methods:
|
|
554
|
+
__on_init__(): Initializes internal dictionaries for cost and component categories.
|
|
555
|
+
update(parent: parent_types): Updates the economic model with the given parent component or system.
|
|
556
|
+
calculate_production(parent, term) -> float: Calculates the production output for the given parent and term. Must be overridden.
|
|
557
|
+
calculate_costs(parent) -> float: Recursively calculates the costs for the given parent component or system.
|
|
558
|
+
sum_cost_references(): Sums the cost references stored in the internal dictionary.
|
|
559
|
+
sum_references(refs): Sums the values of the given references.
|
|
560
|
+
get_prop(ref): Retrieves the property associated with the given reference.
|
|
561
|
+
term_fgen(comp, prop): Generates a function to calculate the term value for the given component and property.
|
|
562
|
+
sum_term_fgen(ref_group): Sums the term functions for the given reference group.
|
|
563
|
+
internal_references(recache=True, numeric_only=False): Gathers and sets internal references for the economic model.
|
|
564
|
+
lifecycle_output() -> dict: Returns lifecycle calculations for the levelized cost of energy (LCOE).
|
|
565
|
+
lifecycle_dataframe() -> pandas.DataFrame: Simulates the economics lifecycle and stores the results in a term-based dataframe.
|
|
566
|
+
_create_term_eval_functions(): Creates evaluation functions for term-based calculations grouped by categories and components.
|
|
567
|
+
_gather_cost_references(parent: "System"): Gathers cost references from the parent component or system.
|
|
568
|
+
_extract_cost_references(conf: "CostModel", bse: str): Extracts cost references from the given cost model configuration.
|
|
569
|
+
cost_references(): Returns the internal dictionary of cost references.
|
|
570
|
+
combine_cost() -> float: Returns the combined cost of the economic model.
|
|
571
|
+
output() -> float: Returns the calculated output of the economic model.
|
|
572
|
+
"""
|
|
573
|
+
|
|
574
|
+
term_length: int = attrs.field(default=0)
|
|
575
|
+
discount_rate: float = attrs.field(default=0.0)
|
|
576
|
+
fixed_output: float = attrs.field(default=numpy.nan)
|
|
577
|
+
output_type: str = attrs.field(default="generic")
|
|
578
|
+
terms_per_year: int = attrs.field(default=1)
|
|
579
|
+
|
|
580
|
+
_calc_output: float = None
|
|
581
|
+
_costs: float = None
|
|
582
|
+
_cost_references: dict = None
|
|
583
|
+
_cost_categories: dict = None
|
|
584
|
+
_comp_categories: dict = None
|
|
585
|
+
_comp_costs: dict = None
|
|
586
|
+
parent: parent_types
|
|
587
|
+
|
|
588
|
+
def __on_init__(self):
|
|
589
|
+
self._cost_categories = collections.defaultdict(list)
|
|
590
|
+
self._comp_categories = collections.defaultdict(list)
|
|
591
|
+
self._comp_costs = dict()
|
|
592
|
+
|
|
593
|
+
def update(self, parent: parent_types):
|
|
594
|
+
if self.log_level <= 5:
|
|
595
|
+
self.msg(f"econ updating costs: {parent}", lvl=5)
|
|
596
|
+
|
|
597
|
+
self.parent = parent
|
|
598
|
+
|
|
599
|
+
# this is kinda expensive to do every time, but we need to do it to get the costs
|
|
600
|
+
self._gather_cost_references(parent)
|
|
601
|
+
self._calc_output = self.calculate_production(parent, 0)
|
|
602
|
+
self._costs = self.calculate_costs(parent)
|
|
603
|
+
|
|
604
|
+
if self._calc_output is None:
|
|
605
|
+
self.warning(f"no economic output!")
|
|
606
|
+
if self._costs is None:
|
|
607
|
+
self.warning(f"no economic costs!")
|
|
608
|
+
|
|
609
|
+
self._anything_changed = True
|
|
610
|
+
|
|
611
|
+
# TODO: expand this...
|
|
612
|
+
@solver_cached
|
|
613
|
+
def econ_output(self):
|
|
614
|
+
return self.lifecycle_output
|
|
615
|
+
|
|
616
|
+
@system_property(label="cost/output")
|
|
617
|
+
def levelized_cost(self) -> float:
|
|
618
|
+
"""Price per kwh"""
|
|
619
|
+
eco = self.econ_output
|
|
620
|
+
return eco["summary.levelized_cost"]
|
|
621
|
+
|
|
622
|
+
@system_property
|
|
623
|
+
def total_cost(self) -> float:
|
|
624
|
+
eco = self.econ_output
|
|
625
|
+
return eco["summary.total_cost"]
|
|
626
|
+
|
|
627
|
+
@system_property
|
|
628
|
+
def levelized_output(self) -> float:
|
|
629
|
+
"""ouput per dollar (KW/$)"""
|
|
630
|
+
eco = self.econ_output
|
|
631
|
+
return eco["summary.levelized_output"]
|
|
632
|
+
|
|
633
|
+
@system_property
|
|
634
|
+
def term_years(self) -> float:
|
|
635
|
+
"""ouput per dollar (KW/$)"""
|
|
636
|
+
eco = self.econ_output
|
|
637
|
+
return eco["summary.years"]
|
|
638
|
+
|
|
639
|
+
# Calculate Output
|
|
640
|
+
def calculate_production(self, parent, term) -> float:
|
|
641
|
+
"""must override this function and set economic_output"""
|
|
642
|
+
return numpy.nansum([0, self.fixed_output])
|
|
643
|
+
|
|
644
|
+
def calculate_costs(self, parent) -> float:
|
|
645
|
+
"""recursively accounts for costs in the parent, its children recursively."""
|
|
646
|
+
return self.sum_cost_references()
|
|
647
|
+
|
|
648
|
+
# Reference Utilitly Functions
|
|
649
|
+
# Set Costs over time "flat" through ref trickery...
|
|
650
|
+
def sum_cost_references(self):
|
|
651
|
+
cst = 0
|
|
652
|
+
for k, v in self._cost_references.items():
|
|
653
|
+
if k.endswith("item_cost"):
|
|
654
|
+
val = v.value()
|
|
655
|
+
if self.log_level < 2:
|
|
656
|
+
self.msg(f"add item cost: {k}|{val}")
|
|
657
|
+
cst += val
|
|
658
|
+
else:
|
|
659
|
+
if self.log_level < 2:
|
|
660
|
+
self.msg(f"skip cost: {k}")
|
|
661
|
+
return cst
|
|
662
|
+
|
|
663
|
+
def sum_references(self, refs):
|
|
664
|
+
return numpy.nansum([r.value() for r in refs])
|
|
665
|
+
|
|
666
|
+
def get_prop(self, ref):
|
|
667
|
+
if ref.use_dict:
|
|
668
|
+
return ref.key
|
|
669
|
+
elif ref.key in ref.comp.class_cost_properties():
|
|
670
|
+
return ref.comp.class_cost_properties()[ref.key]
|
|
671
|
+
# elif ref.key in ref.comp.system_properties_classdef():
|
|
672
|
+
# return ref.comp.system_properties_classdef()[ref.key]
|
|
673
|
+
# else:
|
|
674
|
+
# raise KeyError(f'ref key doesnt exist as property: {ref.key}')
|
|
675
|
+
|
|
676
|
+
def term_fgen(self, comp, prop):
|
|
677
|
+
if isinstance(comp, dict):
|
|
678
|
+
return lambda term: comp[prop] if term == 0 else 0
|
|
679
|
+
return lambda term: (
|
|
680
|
+
prop.__get__(comp) if prop.apply_at_term(comp, term, self) else 0
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
def sum_term_fgen(self, ref_group):
|
|
684
|
+
term_funs = [self.term_fgen(ref.comp, self.get_prop(ref)) for ref in ref_group]
|
|
685
|
+
return lambda term: numpy.nansum([t(term) for t in term_funs])
|
|
686
|
+
|
|
687
|
+
# Gather & Set References (the magic!)
|
|
688
|
+
# TODO: update internal_references callback to problem
|
|
689
|
+
def internal_references(self, recache=True, numeric_only=False):
|
|
690
|
+
"""standard component references are"""
|
|
691
|
+
|
|
692
|
+
recache = True # override
|
|
693
|
+
|
|
694
|
+
d = self._gather_references()
|
|
695
|
+
self._create_term_eval_functions()
|
|
696
|
+
# Gather all internal economic variables and report costs
|
|
697
|
+
props = d["properties"]
|
|
698
|
+
|
|
699
|
+
# calculate lifecycle costs
|
|
700
|
+
lc_out = self.lifecycle_output
|
|
701
|
+
|
|
702
|
+
if self._cost_references:
|
|
703
|
+
props.update(**self._cost_references)
|
|
704
|
+
|
|
705
|
+
# lookup ref from the cost categories dictionary, recreate every time
|
|
706
|
+
if self._cost_categories:
|
|
707
|
+
for key, refs in self._cost_categories.items():
|
|
708
|
+
props[key] = Ref(
|
|
709
|
+
self._cost_categories,
|
|
710
|
+
key,
|
|
711
|
+
False,
|
|
712
|
+
False,
|
|
713
|
+
eval_f=self.sum_references,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
if self._comp_categories:
|
|
717
|
+
for key, refs in self._comp_categories.items():
|
|
718
|
+
props[key] = Ref(
|
|
719
|
+
self._comp_categories,
|
|
720
|
+
key,
|
|
721
|
+
False,
|
|
722
|
+
False,
|
|
723
|
+
eval_f=self.sum_references,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
for k, v in lc_out.items():
|
|
727
|
+
props[k] = Ref(lc_out, k, False, False)
|
|
728
|
+
|
|
729
|
+
return d
|
|
730
|
+
|
|
731
|
+
def cost_summary(self, annualized=False, do_print=True, ignore_zero=True):
|
|
732
|
+
"""
|
|
733
|
+
Generate a summary of costs, optionally annualized, and optionally print the summary.
|
|
734
|
+
:param annualized: If True, include only annualized costs in the summary. Default is False.
|
|
735
|
+
:type annualized: bool
|
|
736
|
+
:param do_print: If True, print the summary to the console. Default is True.
|
|
737
|
+
:type do_print: bool
|
|
738
|
+
:param ignore_zero: If True, ignore costs with a value of zero. Default is True.
|
|
739
|
+
:type ignore_zero: bool
|
|
740
|
+
:return: A dictionary containing component costs, skipped costs, and summary.
|
|
741
|
+
:rtype: dict
|
|
742
|
+
"""
|
|
743
|
+
|
|
744
|
+
dct = self.lifecycle_output
|
|
745
|
+
cols = list(dct.keys())
|
|
746
|
+
|
|
747
|
+
skippd = set()
|
|
748
|
+
comp_costs = collections.defaultdict(dict)
|
|
749
|
+
comp_nums = collections.defaultdict(dict)
|
|
750
|
+
summary = {}
|
|
751
|
+
costs = {
|
|
752
|
+
"comps": comp_costs,
|
|
753
|
+
"skip": skippd,
|
|
754
|
+
"summary": summary,
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
def abriv(val):
|
|
758
|
+
evall = abs(val)
|
|
759
|
+
if evall > 1e6:
|
|
760
|
+
return f"{val/1E6:>12.4f} M"
|
|
761
|
+
elif evall > 1e3:
|
|
762
|
+
return f"{val/1E3:>12.2f} k"
|
|
763
|
+
return f"{val:>12.2f}"
|
|
764
|
+
|
|
765
|
+
for col in cols:
|
|
766
|
+
is_ann = ".annualized." in col
|
|
767
|
+
val = dct[col]
|
|
768
|
+
|
|
769
|
+
# handle no value case
|
|
770
|
+
if val == 0 and ignore_zero:
|
|
771
|
+
continue
|
|
772
|
+
|
|
773
|
+
if ".cost." in col and is_ann == annualized:
|
|
774
|
+
base, cst = col.split(".cost.")
|
|
775
|
+
if base == "lifecycle":
|
|
776
|
+
ckey = f"cost.{cst}" # you're at the top bby
|
|
777
|
+
else:
|
|
778
|
+
ckey = f"{base.replace('lifecycle.','')}.cost.{cst}"
|
|
779
|
+
# print(ckey,str(self._comp_costs.keys()))
|
|
780
|
+
comp_costs[base][cst] = val
|
|
781
|
+
comp_nums[base][cst] = get_num_from_cost_prop(self._comp_costs[ckey])
|
|
782
|
+
elif col.startswith("summary."):
|
|
783
|
+
summary[col.replace("summary.", "")] = val
|
|
784
|
+
else:
|
|
785
|
+
self.msg(f"skipping: {col}")
|
|
786
|
+
|
|
787
|
+
total_cost = sum([sum(list(cc.values())) for cc in comp_costs.values()])
|
|
788
|
+
|
|
789
|
+
# provide consistent format
|
|
790
|
+
hdr = "{key:<32}|\t{value:12.10f}"
|
|
791
|
+
fmt = "{key:<32}|\t{fmt:<24} | {total:^12} | {pct:3.3f}%"
|
|
792
|
+
title = f"COST SUMMARY: {self.parent.identity}"
|
|
793
|
+
if do_print:
|
|
794
|
+
self.info("#" * 80)
|
|
795
|
+
self.info(f"{title:^80}")
|
|
796
|
+
self.info("_" * 80)
|
|
797
|
+
# possible core values
|
|
798
|
+
if NI := getattr(self, "num_items", None): # num items
|
|
799
|
+
self.info(hdr.format(key="num_items", value=NI))
|
|
800
|
+
if TI := getattr(self, "term_length", None):
|
|
801
|
+
self.info(hdr.format(key="term_length", value=TI))
|
|
802
|
+
if DR := getattr(self, "discount_rate", None):
|
|
803
|
+
self.info(hdr.format(key="discount_rate", value=DR))
|
|
804
|
+
if DR := getattr(self, "output", None):
|
|
805
|
+
self.info(hdr.format(key="output", value=DR))
|
|
806
|
+
# summary items
|
|
807
|
+
for key, val in summary.items():
|
|
808
|
+
self.info(hdr.format(key=key, value=val))
|
|
809
|
+
itcst = "{val:>24}".format(val="TOTAL----->")
|
|
810
|
+
self.info("=" * 80)
|
|
811
|
+
self.info(
|
|
812
|
+
fmt.format(key="COMBINED", fmt=itcst, total=abriv(total_cost), pct=100)
|
|
813
|
+
)
|
|
814
|
+
self.info("-" * 80)
|
|
815
|
+
# itemization
|
|
816
|
+
sgroups = lambda kv: sum(list(kv[-1].values()))
|
|
817
|
+
for base, items in sorted(comp_costs.items(), key=sgroups, reverse=True):
|
|
818
|
+
# skip if all zeros (allow for net negative costs)
|
|
819
|
+
if (subtot := sum([abs(v) for v in items.values()])) > 0:
|
|
820
|
+
# self.info(f' {base:<35}| total ---> {abriv(subtot)} | {subtot*100/total_cost:3.0f}%')
|
|
821
|
+
pct = subtot * 100 / total_cost
|
|
822
|
+
itcst = "{val:>24}".format(val="TOTAL----->")
|
|
823
|
+
# todo; add number of items for cost comp
|
|
824
|
+
adj_base = base.replace("lifecycle.", "")
|
|
825
|
+
self.info(
|
|
826
|
+
fmt.format(
|
|
827
|
+
key=adj_base,
|
|
828
|
+
fmt=itcst,
|
|
829
|
+
total=abriv(subtot),
|
|
830
|
+
pct=pct,
|
|
831
|
+
)
|
|
832
|
+
)
|
|
833
|
+
# Sort costs by value
|
|
834
|
+
for key, val in sorted(
|
|
835
|
+
items.items(), key=lambda kv: kv[-1], reverse=True
|
|
836
|
+
):
|
|
837
|
+
if val == 0 or numpy.isnan(val):
|
|
838
|
+
continue # skip zero costs
|
|
839
|
+
# self.info(f' \t{key:<32}|{abriv(val)} | {val*100/total_cost:^3.0f}%')
|
|
840
|
+
tot = abriv(val)
|
|
841
|
+
pct = val * 100 / total_cost
|
|
842
|
+
num = comp_nums[base][key]
|
|
843
|
+
itcst = (
|
|
844
|
+
f"{abriv(val/num):^18} x {num:3.0f}" if num != 0 else "0"
|
|
845
|
+
)
|
|
846
|
+
self.info(
|
|
847
|
+
fmt.format(key="-" + key, fmt=itcst, total=tot, pct=pct)
|
|
848
|
+
)
|
|
849
|
+
self.info("-" * 80) # section break
|
|
850
|
+
self.info("#" * 80)
|
|
851
|
+
return costs
|
|
852
|
+
|
|
853
|
+
@property
|
|
854
|
+
def lifecycle_output(self) -> dict:
|
|
855
|
+
"""return lifecycle calculations for lcoe"""
|
|
856
|
+
totals = {}
|
|
857
|
+
totals["category"] = lifecat = {}
|
|
858
|
+
totals["annualized"] = annul = {}
|
|
859
|
+
summary = {}
|
|
860
|
+
out = {"summary": summary, "lifecycle": totals}
|
|
861
|
+
|
|
862
|
+
lc = self.lifecycle_dataframe
|
|
863
|
+
for c in lc.columns:
|
|
864
|
+
if "category" not in c and "cost" not in c:
|
|
865
|
+
continue
|
|
866
|
+
tot = lc[c].sum() # lifecycle cost
|
|
867
|
+
if "category" in c:
|
|
868
|
+
c_ = c.replace("category.", "")
|
|
869
|
+
lifecat[c_] = tot
|
|
870
|
+
else:
|
|
871
|
+
totals[c] = tot
|
|
872
|
+
annul[c] = tot * self.terms_per_year / (self.term_length + 1)
|
|
873
|
+
|
|
874
|
+
summary["total_cost"] = lc.term_cost.sum()
|
|
875
|
+
summary["years"] = lc.year.max() + 1
|
|
876
|
+
LC = lc.levelized_cost.sum()
|
|
877
|
+
LO = lc.levelized_output.sum()
|
|
878
|
+
summary["levelized_cost"] = LC / LO if LO != 0 else numpy.nan
|
|
879
|
+
summary["levelized_output"] = LO / LC if LC != 0 else numpy.nan
|
|
880
|
+
|
|
881
|
+
out2 = dict(gend(out))
|
|
882
|
+
self._term_output = out2
|
|
883
|
+
return self._term_output
|
|
884
|
+
|
|
885
|
+
@property
|
|
886
|
+
def lifecycle_dataframe(self) -> pandas.DataFrame:
|
|
887
|
+
"""simulates the economics lifecycle and stores the results in a term based dataframe"""
|
|
888
|
+
out = []
|
|
889
|
+
|
|
890
|
+
if self.term_length == 0:
|
|
891
|
+
rng = [0]
|
|
892
|
+
else:
|
|
893
|
+
rng = list(range(0, self.term_length))
|
|
894
|
+
|
|
895
|
+
for i in rng:
|
|
896
|
+
t = i
|
|
897
|
+
row = {"term": t, "year": t / self.terms_per_year}
|
|
898
|
+
out.append(row)
|
|
899
|
+
for k, sum_f in self._term_comp_category.items():
|
|
900
|
+
row[k] = sum_f(t)
|
|
901
|
+
for k, sum_f in self._term_cost_category.items():
|
|
902
|
+
row[k] = sum_f(t)
|
|
903
|
+
for k, sum_f in self._term_comp_cost.items():
|
|
904
|
+
row[k] = sum_f(t)
|
|
905
|
+
row["term_cost"] = tc = numpy.nansum(
|
|
906
|
+
[v(t) for v in self._term_comp_cost.values()]
|
|
907
|
+
)
|
|
908
|
+
row["levelized_cost"] = tc * (1 + self.discount_rate) ** (-1 * t)
|
|
909
|
+
row["output"] = output = self.calculate_production(self.parent, t)
|
|
910
|
+
row["levelized_output"] = output * (1 + self.discount_rate) ** (-1 * t)
|
|
911
|
+
|
|
912
|
+
return pandas.DataFrame(out)
|
|
913
|
+
|
|
914
|
+
def _create_term_eval_functions(self):
|
|
915
|
+
"""uses reference summation grouped by categories & component"""
|
|
916
|
+
self._term_comp_category = {}
|
|
917
|
+
if self._comp_categories:
|
|
918
|
+
for k, vrefs in self._comp_categories.items():
|
|
919
|
+
self._term_comp_category[k] = self.sum_term_fgen(vrefs)
|
|
920
|
+
|
|
921
|
+
self._term_cost_category = {}
|
|
922
|
+
if self._cost_categories:
|
|
923
|
+
for k, vrefs in self._cost_categories.items():
|
|
924
|
+
self._term_cost_category[k] = self.sum_term_fgen(vrefs)
|
|
925
|
+
|
|
926
|
+
self._term_comp_cost = {}
|
|
927
|
+
if self._comp_costs:
|
|
928
|
+
for k, ref in self._comp_costs.items():
|
|
929
|
+
prop = self.get_prop(ref)
|
|
930
|
+
self._term_comp_cost[k] = self.term_fgen(ref.comp, prop)
|
|
931
|
+
|
|
932
|
+
def _gather_cost_references(self, parent: "System"):
|
|
933
|
+
"""put many tabulation.Ref objects into a dictionary to act as additional references for this economics model.
|
|
934
|
+
|
|
935
|
+
References are found from a walk through the parent slots through all child slots
|
|
936
|
+
"""
|
|
937
|
+
self._cost_references = CST = {}
|
|
938
|
+
comps = {}
|
|
939
|
+
comp_set = set()
|
|
940
|
+
|
|
941
|
+
# reset data
|
|
942
|
+
# groupings of components, categories, and pairs of components and categories
|
|
943
|
+
self._cost_categories = collections.defaultdict(list)
|
|
944
|
+
self._comp_categories = collections.defaultdict(list)
|
|
945
|
+
self._comp_costs = dict()
|
|
946
|
+
|
|
947
|
+
for key, level, conf in parent.go_through_configurations(check_config=False):
|
|
948
|
+
# skip self
|
|
949
|
+
if conf is self:
|
|
950
|
+
continue
|
|
951
|
+
|
|
952
|
+
bse = f"{key}." if key else ""
|
|
953
|
+
# prevent duplicates'
|
|
954
|
+
if conf in comp_set:
|
|
955
|
+
continue
|
|
956
|
+
|
|
957
|
+
elif isinstance(conf, Configuration):
|
|
958
|
+
comp_set.add(conf)
|
|
959
|
+
else:
|
|
960
|
+
comp_set.add(key)
|
|
961
|
+
|
|
962
|
+
_base = key.split(".")
|
|
963
|
+
kbase = ".".join(_base[:-1])
|
|
964
|
+
comp_key = _base[-1]
|
|
965
|
+
|
|
966
|
+
self.debug(f"checking {key} {comp_key} {kbase}")
|
|
967
|
+
|
|
968
|
+
# 0. Get Costs Directly From the cost model instance
|
|
969
|
+
if isinstance(conf, CostModel):
|
|
970
|
+
comps[key] = conf
|
|
971
|
+
self.debug(f"adding cost model for {kbase}.{comp_key}")
|
|
972
|
+
self._extract_cost_references(conf, bse)
|
|
973
|
+
|
|
974
|
+
# Look For defaults!
|
|
975
|
+
# 1. try looking for already parsed components (top down)
|
|
976
|
+
elif kbase and kbase in comps:
|
|
977
|
+
child = comps[kbase]
|
|
978
|
+
if (
|
|
979
|
+
isinstance(child, CostModel)
|
|
980
|
+
and hasattr(child.parent, "_slot_costs")
|
|
981
|
+
and child.parent._slot_costs
|
|
982
|
+
and comp_key in child.parent._slot_costs
|
|
983
|
+
):
|
|
984
|
+
self.debug(f"adding cost for {kbase}.{comp_key}")
|
|
985
|
+
compcanidate = child._slot_costs[comp_key]
|
|
986
|
+
if isinstance(compcanidate, CostModel):
|
|
987
|
+
self.debug(f"dflt child costmodel {kbase}.{comp_key}")
|
|
988
|
+
self._extract_cost_references(compcanidate, bse + "cost.")
|
|
989
|
+
else:
|
|
990
|
+
_key = bse + "cost.item_cost"
|
|
991
|
+
self.debug(f"dflt child cost for {kbase}.{comp_key}")
|
|
992
|
+
CST[_key] = ref = Ref(
|
|
993
|
+
child._slot_costs,
|
|
994
|
+
comp_key,
|
|
995
|
+
False,
|
|
996
|
+
False,
|
|
997
|
+
eval_f=eval_slot_cost,
|
|
998
|
+
)
|
|
999
|
+
cc = "unit"
|
|
1000
|
+
self._comp_costs[_key] = ref
|
|
1001
|
+
self._cost_categories["category." + cc].append(ref)
|
|
1002
|
+
self._comp_categories[bse + "category." + cc].append(ref)
|
|
1003
|
+
|
|
1004
|
+
# 2. try looking at the parent
|
|
1005
|
+
elif (
|
|
1006
|
+
isinstance(parent, CostModel)
|
|
1007
|
+
and kbase == ""
|
|
1008
|
+
and comp_key in parent._slot_costs
|
|
1009
|
+
):
|
|
1010
|
+
compcanidate = parent._slot_costs[comp_key]
|
|
1011
|
+
if isinstance(compcanidate, CostModel):
|
|
1012
|
+
self.debug(f"dflt parent cost model for {kbase}.{comp_key}")
|
|
1013
|
+
self._extract_cost_references(compcanidate, bse + "cost.")
|
|
1014
|
+
else:
|
|
1015
|
+
self.debug(f"dflt parent cost for {kbase}.{comp_key}")
|
|
1016
|
+
_key = bse + "cost.item_cost"
|
|
1017
|
+
CST[_key] = ref = Ref(
|
|
1018
|
+
parent._slot_costs,
|
|
1019
|
+
comp_key,
|
|
1020
|
+
False,
|
|
1021
|
+
False,
|
|
1022
|
+
eval_f=eval_slot_cost,
|
|
1023
|
+
)
|
|
1024
|
+
cc = "unit"
|
|
1025
|
+
self._comp_costs[_key] = ref
|
|
1026
|
+
self._cost_categories["category." + cc].append(ref)
|
|
1027
|
+
self._comp_categories[bse + "category." + cc].append(ref)
|
|
1028
|
+
|
|
1029
|
+
else:
|
|
1030
|
+
self.debug(f"unhandled cost: {key}")
|
|
1031
|
+
|
|
1032
|
+
self._cost_references = CST
|
|
1033
|
+
self._anything_changed = True
|
|
1034
|
+
return CST
|
|
1035
|
+
|
|
1036
|
+
def _extract_cost_references(self, conf: "CostModel", bse: str):
|
|
1037
|
+
# Add cost fields
|
|
1038
|
+
_key = bse + "item_cost"
|
|
1039
|
+
CST = self._cost_references
|
|
1040
|
+
if self.log_level < 5:
|
|
1041
|
+
self.msg(f"extracting costs from {bse}|{conf.identity}", lvl=5)
|
|
1042
|
+
|
|
1043
|
+
# cost properties of conf item
|
|
1044
|
+
for cost_nm, cost_prop in conf.class_cost_properties().items():
|
|
1045
|
+
_key = bse + "cost." + cost_nm
|
|
1046
|
+
CST[_key] = ref = Ref(conf, cost_nm, True, False)
|
|
1047
|
+
self._comp_costs[_key] = ref
|
|
1048
|
+
|
|
1049
|
+
# If there are categories we'll add references to later sum them
|
|
1050
|
+
if cost_prop.cost_categories:
|
|
1051
|
+
for cc in cost_prop.cost_categories:
|
|
1052
|
+
self._cost_categories["category." + cc].append(ref)
|
|
1053
|
+
self._comp_categories[bse + "category." + cc].append(ref)
|
|
1054
|
+
else:
|
|
1055
|
+
# we'll reference it as misc
|
|
1056
|
+
cc = "misc"
|
|
1057
|
+
self._cost_categories["category." + cc].append(ref)
|
|
1058
|
+
self._comp_categories[bse + "category." + cc].append(ref)
|
|
1059
|
+
|
|
1060
|
+
comps_act = conf.internal_components()
|
|
1061
|
+
if self.log_level < 10:
|
|
1062
|
+
self.msg(
|
|
1063
|
+
f"{conf.identity if conf else conf} active components: {comps_act}",
|
|
1064
|
+
lvl=5,
|
|
1065
|
+
)
|
|
1066
|
+
# add slot costs with current items (skip class defaults)
|
|
1067
|
+
# TODO: remove defaults costs
|
|
1068
|
+
for slot_name, slot_value in conf._slot_costs.items():
|
|
1069
|
+
# Skip items that are internal components
|
|
1070
|
+
if slot_name in comps_act:
|
|
1071
|
+
self.debug(f"skipping slot {slot_name}")
|
|
1072
|
+
continue
|
|
1073
|
+
else:
|
|
1074
|
+
self.debug(f"adding slot {conf}.{slot_name}")
|
|
1075
|
+
# Check if current slot isn't occupied
|
|
1076
|
+
cur_slot = getattr(conf, slot_name)
|
|
1077
|
+
_key = bse + slot_name + ".cost.item_cost"
|
|
1078
|
+
if not isinstance(cur_slot, Configuration) and _key not in CST:
|
|
1079
|
+
CST[_key] = ref = Ref(
|
|
1080
|
+
conf._slot_costs,
|
|
1081
|
+
slot_name,
|
|
1082
|
+
False,
|
|
1083
|
+
False,
|
|
1084
|
+
eval_f=eval_slot_cost,
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
cc = "unit"
|
|
1088
|
+
self._comp_costs[_key] = ref
|
|
1089
|
+
self._cost_categories["category." + cc].append(ref)
|
|
1090
|
+
self._comp_categories[bse + "category." + cc].append(ref)
|
|
1091
|
+
|
|
1092
|
+
elif _key in CST:
|
|
1093
|
+
self.debug(f"skipping key {_key}")
|
|
1094
|
+
|
|
1095
|
+
# add base class slot values when comp was none (recursively)
|
|
1096
|
+
for compnm, comp in conf.internal_configurations(False, none_ok=True).items():
|
|
1097
|
+
if comp is None:
|
|
1098
|
+
if self.log_level < 5:
|
|
1099
|
+
self.msg(
|
|
1100
|
+
f"{conf} looking up base class costs for {compnm}",
|
|
1101
|
+
lvl=5,
|
|
1102
|
+
)
|
|
1103
|
+
comp_cls = conf.slots_attributes(attr_type=True)[compnm].accepted
|
|
1104
|
+
for cc in comp_cls:
|
|
1105
|
+
if issubclass(cc, CostModel):
|
|
1106
|
+
if cc._slot_costs:
|
|
1107
|
+
if self.log_level < 5:
|
|
1108
|
+
self.msg(f"{conf} looking up base slot cost for {cc}")
|
|
1109
|
+
for k, v in cc._slot_costs.items():
|
|
1110
|
+
_key = bse + compnm + "." + k + ".cost.item_cost"
|
|
1111
|
+
if _key in CST:
|
|
1112
|
+
if self.log_level < 10:
|
|
1113
|
+
self.debug(f"{conf} skipping dflt key {_key}")
|
|
1114
|
+
# break #skip if already added
|
|
1115
|
+
continue
|
|
1116
|
+
|
|
1117
|
+
if isinstance(v, CostModel):
|
|
1118
|
+
self._extract_cost_references(
|
|
1119
|
+
v, bse + compnm + "." + k + "."
|
|
1120
|
+
)
|
|
1121
|
+
else:
|
|
1122
|
+
if self.log_level < 10:
|
|
1123
|
+
self.debug(
|
|
1124
|
+
f"adding missing cost for {conf}.{compnm}"
|
|
1125
|
+
)
|
|
1126
|
+
CST[_key] = ref = Ref(
|
|
1127
|
+
cc._slot_costs,
|
|
1128
|
+
k,
|
|
1129
|
+
False,
|
|
1130
|
+
False,
|
|
1131
|
+
eval_f=eval_slot_cost,
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
cc = "unit"
|
|
1135
|
+
self._comp_costs[_key] = ref
|
|
1136
|
+
self._cost_categories["category." + cc].append(ref)
|
|
1137
|
+
self._comp_categories[
|
|
1138
|
+
bse + "category." + cc
|
|
1139
|
+
].append(ref)
|
|
1140
|
+
|
|
1141
|
+
break # only add once
|
|
1142
|
+
|
|
1143
|
+
elif isinstance(comp, CostModel):
|
|
1144
|
+
if self.log_level < 10:
|
|
1145
|
+
self.debug(f"{conf} using actual costs for {comp}")
|
|
1146
|
+
|
|
1147
|
+
@property
|
|
1148
|
+
def cost_references(self):
|
|
1149
|
+
return self._cost_references
|
|
1150
|
+
|
|
1151
|
+
@system_property
|
|
1152
|
+
def combine_cost(self) -> float:
|
|
1153
|
+
if self._costs is None:
|
|
1154
|
+
return 0
|
|
1155
|
+
return self._costs
|
|
1156
|
+
|
|
1157
|
+
@system_property
|
|
1158
|
+
def output(self) -> float:
|
|
1159
|
+
if self._calc_output is None:
|
|
1160
|
+
return 0
|
|
1161
|
+
return self._calc_output
|
|
1162
|
+
|
|
1163
|
+
@property
|
|
1164
|
+
def cost_category_store(self):
|
|
1165
|
+
D = collections.defaultdict(list)
|
|
1166
|
+
Acat = set()
|
|
1167
|
+
for catkey, cdict in self._cost_categories.items():
|
|
1168
|
+
for ci in cdict:
|
|
1169
|
+
cprop = getattr(ci.comp.__class__, ci.key)
|
|
1170
|
+
ccat = set(cprop.cost_categories.copy())
|
|
1171
|
+
Acat = Acat.union(ccat)
|
|
1172
|
+
D[f"{ci.comp.classname}|{ci.key:>36}"] = ccat
|
|
1173
|
+
return D, Acat
|
|
1174
|
+
|
|
1175
|
+
def create_cost_graph(self, plot=True):
|
|
1176
|
+
"""creates a graph of the cost model using network X and display it"""
|
|
1177
|
+
import collections
|
|
1178
|
+
import networkx as nx
|
|
1179
|
+
|
|
1180
|
+
D = collections.defaultdict(dict)
|
|
1181
|
+
for catkey, cdict in self._cost_categories.items():
|
|
1182
|
+
for ci in cdict:
|
|
1183
|
+
D[catkey][(ci.comp.classname, ci.key)] = ci
|
|
1184
|
+
|
|
1185
|
+
G = nx.Graph()
|
|
1186
|
+
|
|
1187
|
+
for d, dk in D.items():
|
|
1188
|
+
print(d.upper())
|
|
1189
|
+
cat = d.replace("category.", "")
|
|
1190
|
+
G.add_node(cat, category=cat)
|
|
1191
|
+
for kk, r in dk.items():
|
|
1192
|
+
cmp = kk[0]
|
|
1193
|
+
edge = kk[1]
|
|
1194
|
+
if cmp not in G.nodes:
|
|
1195
|
+
G.add_node(cmp, component=cmp)
|
|
1196
|
+
G.add_edge(cmp, cat, cost=edge)
|
|
1197
|
+
# print(kk)
|
|
1198
|
+
|
|
1199
|
+
# pos = nx.nx_agraph.graphviz_layout(G)
|
|
1200
|
+
# nx.draw(G, pos=pos)
|
|
1201
|
+
# nx.draw(G,with_labels=True)
|
|
1202
|
+
|
|
1203
|
+
if plot:
|
|
1204
|
+
categories = nx.get_node_attributes(G, "category").keys()
|
|
1205
|
+
components = nx.get_node_attributes(G, "component").keys()
|
|
1206
|
+
|
|
1207
|
+
cm = []
|
|
1208
|
+
for nd in G:
|
|
1209
|
+
if nd in categories:
|
|
1210
|
+
cm.append("cyan")
|
|
1211
|
+
else:
|
|
1212
|
+
cm.append("pink")
|
|
1213
|
+
|
|
1214
|
+
pos = nx.spring_layout(G, k=0.2, iterations=20, scale=1)
|
|
1215
|
+
nx.draw(
|
|
1216
|
+
G,
|
|
1217
|
+
node_color=cm,
|
|
1218
|
+
with_labels=True,
|
|
1219
|
+
pos=pos,
|
|
1220
|
+
arrows=True,
|
|
1221
|
+
font_size=10,
|
|
1222
|
+
font_color="0.09",
|
|
1223
|
+
font_weight="bold",
|
|
1224
|
+
node_size=200,
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
return G
|
|
1228
|
+
|
|
1229
|
+
def cost_matrix(self):
|
|
1230
|
+
D, Cats = self.cost_category_store
|
|
1231
|
+
X = list(sorted(Cats))
|
|
1232
|
+
C = list(sorted(D.keys()))
|
|
1233
|
+
M = []
|
|
1234
|
+
for k in C:
|
|
1235
|
+
cats = D[k]
|
|
1236
|
+
M.append([True if x in cats else numpy.nan for x in X])
|
|
1237
|
+
|
|
1238
|
+
Mx = numpy.array(M)
|
|
1239
|
+
X = numpy.array(X)
|
|
1240
|
+
C = numpy.array(C)
|
|
1241
|
+
return Mx, X, C
|
|
1242
|
+
|
|
1243
|
+
def create_cost_category_table(self):
|
|
1244
|
+
"""creates a table of costs and categories"""
|
|
1245
|
+
Mx, X, C = self.cost_matrix()
|
|
1246
|
+
|
|
1247
|
+
fig, ax = subplots(figsize=(12, 12))
|
|
1248
|
+
|
|
1249
|
+
Mc = numpy.nansum(Mx, axis=0)
|
|
1250
|
+
x = numpy.argsort(Mc)
|
|
1251
|
+
|
|
1252
|
+
Xs = X[x]
|
|
1253
|
+
ax.imshow(Mx[:, x])
|
|
1254
|
+
ax.set_yticklabels(C, fontdict={"family": "monospace", "size": 8})
|
|
1255
|
+
ax.set_yticks(numpy.arange(len(C)))
|
|
1256
|
+
ax.set_xticklabels(Xs, fontdict={"family": "monospace", "size": 8})
|
|
1257
|
+
ax.set_xticks(numpy.arange(len(Xs)))
|
|
1258
|
+
ax.grid(which="major", linestyle=":", color="k", zorder=0)
|
|
1259
|
+
xticks(rotation=90)
|
|
1260
|
+
fig.tight_layout()
|
|
1261
|
+
|
|
1262
|
+
def determine_exclusive_cost_categories(
|
|
1263
|
+
self,
|
|
1264
|
+
include_categories=None,
|
|
1265
|
+
ignore_categories: set = None,
|
|
1266
|
+
min_groups: int = 2,
|
|
1267
|
+
max_group_size=None,
|
|
1268
|
+
min_score=0.95,
|
|
1269
|
+
include_item_cost=False,
|
|
1270
|
+
):
|
|
1271
|
+
"""looks at all possible combinations of cost categories, scoring them based on coverage of costs, and not allowing any double accounting of costs. This is an NP-complete problem and will take a long time for large numbers of items. You can add ignore_categories to ignore certain categories"""
|
|
1272
|
+
import itertools
|
|
1273
|
+
|
|
1274
|
+
Mx, X, C = self.cost_matrix()
|
|
1275
|
+
|
|
1276
|
+
bad = []
|
|
1277
|
+
solutions = []
|
|
1278
|
+
inx = {k: i for i, k in enumerate(X)}
|
|
1279
|
+
|
|
1280
|
+
assert include_categories is None or set(X).issuperset(
|
|
1281
|
+
include_categories
|
|
1282
|
+
), "include_categories must be subset of cost categories"
|
|
1283
|
+
|
|
1284
|
+
# ignore categories
|
|
1285
|
+
if ignore_categories:
|
|
1286
|
+
X = [x for x in X if x not in ignore_categories]
|
|
1287
|
+
|
|
1288
|
+
if include_categories:
|
|
1289
|
+
# dont include them in pair since they are added to the group explicitly
|
|
1290
|
+
X = [x for x in X if x not in include_categories]
|
|
1291
|
+
|
|
1292
|
+
if not include_item_cost:
|
|
1293
|
+
C = [c for c in C if "item_cost" not in c]
|
|
1294
|
+
|
|
1295
|
+
Num_Costs = len(C)
|
|
1296
|
+
goal_score = Num_Costs * min_score
|
|
1297
|
+
NumCats = len(X) // 2
|
|
1298
|
+
GroupSize = NumCats if max_group_size is None else max_group_size
|
|
1299
|
+
for ni in range(min_groups, GroupSize):
|
|
1300
|
+
print(f"level {ni}/{GroupSize}| {len(solutions)} answers")
|
|
1301
|
+
for cgs in itertools.combinations(X, ni):
|
|
1302
|
+
val = None
|
|
1303
|
+
|
|
1304
|
+
# make the set with included if needed
|
|
1305
|
+
scg = set(cgs)
|
|
1306
|
+
if include_categories:
|
|
1307
|
+
scg = scg.union(include_categories)
|
|
1308
|
+
|
|
1309
|
+
# skip bad groups
|
|
1310
|
+
if any([b.issubset(scg) for b in bad]):
|
|
1311
|
+
# print(f'skipping {cgs}')
|
|
1312
|
+
# sys.stdout.write('.')
|
|
1313
|
+
continue
|
|
1314
|
+
|
|
1315
|
+
good = True # innocent till guilty
|
|
1316
|
+
for cg in cgs:
|
|
1317
|
+
xi = Mx[:, inx[cg]].copy()
|
|
1318
|
+
xi[np.isnan(xi)] = 0
|
|
1319
|
+
if val is None:
|
|
1320
|
+
val = xi
|
|
1321
|
+
else:
|
|
1322
|
+
val = val + xi
|
|
1323
|
+
# determine if any overlap (only pair level)
|
|
1324
|
+
if np.nanmax(val) > 1:
|
|
1325
|
+
print(f"bad {cgs}")
|
|
1326
|
+
bad.append(scg)
|
|
1327
|
+
good = False
|
|
1328
|
+
break
|
|
1329
|
+
|
|
1330
|
+
score = np.nansum(val)
|
|
1331
|
+
if good and score > goal_score:
|
|
1332
|
+
print(f"found good: {scg}")
|
|
1333
|
+
solutions.append({"grp": scg, "score": score, "gsize": ni})
|
|
1334
|
+
|
|
1335
|
+
return solutions
|
|
1336
|
+
|
|
1337
|
+
def cost_categories_from_df(self, df):
|
|
1338
|
+
categories = set()
|
|
1339
|
+
for val in df.columns:
|
|
1340
|
+
m = re.match(
|
|
1341
|
+
re.compile("economics\.lifecycle\.category\.(?s:[a-z]*)$"), val
|
|
1342
|
+
)
|
|
1343
|
+
if m:
|
|
1344
|
+
categories.add(val)
|
|
1345
|
+
return categories
|
|
1346
|
+
|
|
1347
|
+
def plot_cost_categories(self, df, group, cmap="tab20c", make_title=None, ax=None):
|
|
1348
|
+
categories = self.cost_categories_from_df(df)
|
|
1349
|
+
from matplotlib import cm
|
|
1350
|
+
|
|
1351
|
+
# if grps:
|
|
1352
|
+
# assert len(grps) == len(y_vars), 'groups and y_vars must be same length'
|
|
1353
|
+
# assert all([g in categories for g in grps]), 'all groups must be in categories'
|
|
1354
|
+
# TODO: project costs onto y_vars
|
|
1355
|
+
# TODO: ensure groups and y_vars are same length
|
|
1356
|
+
|
|
1357
|
+
color = cm.get_cmap(cmap)
|
|
1358
|
+
styles = {
|
|
1359
|
+
c.replace("economics.lifecycle.category.", ""): {
|
|
1360
|
+
"color": color(i / len(categories))
|
|
1361
|
+
}
|
|
1362
|
+
for i, c in enumerate(categories)
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if make_title is None:
|
|
1366
|
+
|
|
1367
|
+
def make_title(row):
|
|
1368
|
+
return f'{row["name"]}x{row["num_items"]} @{"floating" if row["ldepth"]>50 else "fixed"}'
|
|
1369
|
+
|
|
1370
|
+
# for j,grp in enumerate(groups):
|
|
1371
|
+
figgen = False
|
|
1372
|
+
if ax is None:
|
|
1373
|
+
figgen = True
|
|
1374
|
+
fig, ax = subplots(figsize=(12, 8))
|
|
1375
|
+
else:
|
|
1376
|
+
fig = ax.get_figure()
|
|
1377
|
+
|
|
1378
|
+
titles = []
|
|
1379
|
+
xticks = []
|
|
1380
|
+
data = {}
|
|
1381
|
+
i = 0
|
|
1382
|
+
|
|
1383
|
+
for inx, row in df.iterrows():
|
|
1384
|
+
i += 1
|
|
1385
|
+
tc = row["economics.summary.total_cost"]
|
|
1386
|
+
cat_costs = {
|
|
1387
|
+
k.replace("economics.lifecycle.category.", ""): row[k]
|
|
1388
|
+
for k in categories
|
|
1389
|
+
}
|
|
1390
|
+
# print(i,cat_costs)
|
|
1391
|
+
|
|
1392
|
+
spec_costs = {k: v for k, v in cat_costs.items() if k in group}
|
|
1393
|
+
pos_costs = {k: v for k, v in spec_costs.items() if v >= 0}
|
|
1394
|
+
neg_costs = {k: v for k, v in spec_costs.items() if k not in pos_costs}
|
|
1395
|
+
neg_amt = sum(list(neg_costs.values()))
|
|
1396
|
+
pos_amt = sum(list(pos_costs.values()))
|
|
1397
|
+
|
|
1398
|
+
data[i] = spec_costs.copy()
|
|
1399
|
+
|
|
1400
|
+
com = {"x": i, "width": 0.5, "linewidth": 0}
|
|
1401
|
+
cur = neg_amt
|
|
1402
|
+
for k, v in neg_costs.items():
|
|
1403
|
+
opt = {} if i != 1 else {"label": k}
|
|
1404
|
+
ax.bar(height=abs(v), bottom=cur, **com, **styles[k], **opt)
|
|
1405
|
+
cur += abs(v)
|
|
1406
|
+
for k, v in pos_costs.items():
|
|
1407
|
+
opt = {} if i != 1 else {"label": k}
|
|
1408
|
+
ax.bar(height=abs(v), bottom=cur, **com, **styles[k], **opt)
|
|
1409
|
+
cur += abs(v)
|
|
1410
|
+
xticks.append(com["x"])
|
|
1411
|
+
titles.append(make_title(row))
|
|
1412
|
+
|
|
1413
|
+
# Format the chart
|
|
1414
|
+
ax.legend(loc="upper right")
|
|
1415
|
+
ax.set_xlim([0, i + max(2, 0.2 * i)])
|
|
1416
|
+
ax.set_xticks(xticks)
|
|
1417
|
+
|
|
1418
|
+
ax.set_xticklabels(titles, rotation=90)
|
|
1419
|
+
ylim = ax.get_ylim()
|
|
1420
|
+
ylim = ylim[0] - 0.05 * abs(ylim[0]), ylim[1] + 0.05 * abs(ylim[1])
|
|
1421
|
+
ax.set_yticks(numpy.linspace(*ylim, 50), minor=True)
|
|
1422
|
+
ax.grid(which="major", linestyle="--", color="k", zorder=0)
|
|
1423
|
+
ax.grid(which="minor", linestyle=":", color="k", zorder=0)
|
|
1424
|
+
if figgen:
|
|
1425
|
+
fig.tight_layout()
|
|
1426
|
+
return {"fig": fig, "ax": ax, "data": data}
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
# TODO: add costs for iterable components (wide/narrow modes)
|
|
1430
|
+
# if isinstance(conf,ComponentIter):
|
|
1431
|
+
# conf = conf.current
|
|
1432
|
+
# #if isinstance(conf,CostModel):
|
|
1433
|
+
# # sub_tot += conf.item_cost
|
|
1434
|
+
# if isinstance(conf,ComponentIter):
|
|
1435
|
+
# item = conf.current
|
|
1436
|
+
# if conf.wide:
|
|
1437
|
+
# items = item
|
|
1438
|
+
# else:
|
|
1439
|
+
# items = [items]
|
|
1440
|
+
# else:
|
|
1441
|
+
# items = [conf]
|
|
1442
|
+
# for conf in items:
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
# if isinstance(self,CostModel):
|
|
1446
|
+
# sub_tot += self.item_cost
|
|
1447
|
+
|
|
1448
|
+
# accomodate ComponentIter in wide mode
|
|
1449
|
+
# if isinstance(self,ComponentIter):
|
|
1450
|
+
# item = self.current
|
|
1451
|
+
# if self.wide:
|
|
1452
|
+
# items = item
|
|
1453
|
+
# else:
|
|
1454
|
+
# items = [items]
|
|
1455
|
+
# else:
|
|
1456
|
+
# items = [self]
|
|
1457
|
+
|
|
1458
|
+
# accomodate ComponentIter in wide mode
|
|
1459
|
+
# for item in items:
|