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.
Files changed (69) hide show
  1. engforge/__init__.py +26 -0
  2. engforge/_testing_components.py +864 -0
  3. engforge/analysis.py +257 -0
  4. engforge/attr_dynamics.py +222 -0
  5. engforge/attr_plotting.py +811 -0
  6. engforge/attr_signals.py +116 -0
  7. engforge/attr_slots.py +211 -0
  8. engforge/attr_solver.py +443 -0
  9. engforge/attributes.py +326 -0
  10. engforge/common.py +76 -0
  11. engforge/component_collections.py +276 -0
  12. engforge/components.py +55 -0
  13. engforge/configuration.py +673 -0
  14. engforge/dataframe.py +185 -0
  15. engforge/datastores/__init__.py +116 -0
  16. engforge/datastores/data.py +519 -0
  17. engforge/datastores/gdocs.py +2650 -0
  18. engforge/datastores/reporting.py +935 -0
  19. engforge/datastores/secrets.py +549 -0
  20. engforge/dynamics.py +662 -0
  21. engforge/eng/__init__.py +0 -0
  22. engforge/eng/costs.py +1459 -0
  23. engforge/eng/fluid_material.py +279 -0
  24. engforge/eng/geometry.py +1060 -0
  25. engforge/eng/pipes.py +1211 -0
  26. engforge/eng/prediction.py +366 -0
  27. engforge/eng/solid_materials.py +445 -0
  28. engforge/eng/structure.py +2228 -0
  29. engforge/eng/structure_beams.py +821 -0
  30. engforge/eng/thermodynamics.py +271 -0
  31. engforge/engforge_attributes.py +380 -0
  32. engforge/env_var.py +214 -0
  33. engforge/locations.py +18 -0
  34. engforge/logging.py +290 -0
  35. engforge/patterns.py +449 -0
  36. engforge/problem_context.py +2251 -0
  37. engforge/properties.py +501 -0
  38. engforge/reporting.py +595 -0
  39. engforge/solveable.py +964 -0
  40. engforge/solver.py +316 -0
  41. engforge/solver_utils.py +726 -0
  42. engforge/system.py +143 -0
  43. engforge/system_reference.py +299 -0
  44. engforge/tabulation.py +288 -0
  45. engforge/test/__init__.py +2 -0
  46. engforge/test/_pre_test_structures.py +221 -0
  47. engforge/test/report_testing.py +158 -0
  48. engforge/test/test_airfilter.py +91 -0
  49. engforge/test/test_analysis.py +3 -0
  50. engforge/test/test_comp_iter.py +196 -0
  51. engforge/test/test_composition.py +139 -0
  52. engforge/test/test_costs.py +338 -0
  53. engforge/test/test_dynamics.py +109 -0
  54. engforge/test/test_dynamics_spaces.py +39 -0
  55. engforge/test/test_four_bar.py +97 -0
  56. engforge/test/test_modules.py +72 -0
  57. engforge/test/test_performance.py +93 -0
  58. engforge/test/test_pipes.py +49 -0
  59. engforge/test/test_problem.py +411 -0
  60. engforge/test/test_problem_deepscoping.py +68 -0
  61. engforge/test/test_slider_crank.py +51 -0
  62. engforge/test/test_solver.py +343 -0
  63. engforge/test/test_tabulation.py +233 -0
  64. engforge/typing.py +92 -0
  65. engforge-0.3.0.dist-info/METADATA +392 -0
  66. engforge-0.3.0.dist-info/RECORD +69 -0
  67. engforge-0.3.0.dist-info/WHEEL +5 -0
  68. engforge-0.3.0.dist-info/licenses/LICENSE +7 -0
  69. 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: