bw-functional 0b79__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.
- bw_functional/__init__.py +65 -0
- bw_functional/allocation.py +142 -0
- bw_functional/convert.py +143 -0
- bw_functional/database.py +514 -0
- bw_functional/edge_classes.py +166 -0
- bw_functional/node_classes.py +591 -0
- bw_functional-0b79.dist-info/METADATA +216 -0
- bw_functional-0b79.dist-info/RECORD +11 -0
- bw_functional-0b79.dist-info/WHEEL +5 -0
- bw_functional-0b79.dist-info/licenses/LICENSE +12 -0
- bw_functional-0b79.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
from typing import Optional, Union
|
|
2
|
+
from logging import getLogger
|
|
3
|
+
|
|
4
|
+
from bw2data import databases, get_node, labels
|
|
5
|
+
from bw2data.backends import ExchangeDataset
|
|
6
|
+
from bw2data.errors import UnknownObject, ValidityError
|
|
7
|
+
from bw2data.backends.proxies import Activity, ActivityDataset
|
|
8
|
+
|
|
9
|
+
from .edge_classes import MFExchanges, MFExchange
|
|
10
|
+
|
|
11
|
+
log = getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MFActivity(Activity):
|
|
15
|
+
"""
|
|
16
|
+
A class representing an activity of the functional_sqlite backend.
|
|
17
|
+
|
|
18
|
+
This class extends the `Activity` class to provide additional functionality for managing
|
|
19
|
+
multifunctional activities, including handling exchanges, technosphere, biosphere, and production flows. Subclasses
|
|
20
|
+
methods mostly to make sure we're using the correct edge classes.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
_edges_class = MFExchanges
|
|
24
|
+
_edge_class = MFExchange
|
|
25
|
+
|
|
26
|
+
def save(self, signal: bool = True, data_already_set: bool = False, force_insert: bool = False):
|
|
27
|
+
"""
|
|
28
|
+
Save the activity to the database.
|
|
29
|
+
|
|
30
|
+
This method logs the save operation and delegates the actual saving to the parent `Activity` class.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
signal (bool, optional): Whether to send a signal after saving. Defaults to True.
|
|
34
|
+
data_already_set (bool, optional): Whether the data is already set. Defaults to False.
|
|
35
|
+
force_insert (bool, optional): Whether to force an insert operation. Defaults to False.
|
|
36
|
+
"""
|
|
37
|
+
log.debug(f"Saving {self.__class__.__name__}: {self}")
|
|
38
|
+
super().save(signal, data_already_set, force_insert)
|
|
39
|
+
|
|
40
|
+
def delete(self, signal: bool = True):
|
|
41
|
+
"""
|
|
42
|
+
Delete the activity from the database.
|
|
43
|
+
|
|
44
|
+
This method logs the delete operation and delegates the actual deletion to the parent `Activity` class.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
signal (bool, optional): Whether to send a signal after deletion. Defaults to True.
|
|
48
|
+
"""
|
|
49
|
+
log.debug(f"Deleting {self.__class__.__name__}: {self}")
|
|
50
|
+
super().delete(signal)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def multifunctional(self) -> bool:
|
|
54
|
+
"""
|
|
55
|
+
Check if the activity is multifunctional.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
bool: Always returns False, indicating the activity is not multifunctional by default.
|
|
59
|
+
"""
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def exchanges(self, exchanges_class=None, kinds=None, reverse=False):
|
|
63
|
+
"""
|
|
64
|
+
Retrieve exchanges associated with the activity.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
exchanges_class (type, optional): The class to use for exchanges. Defaults to None.
|
|
68
|
+
kinds (list, optional): The types of exchanges to retrieve. Defaults to None.
|
|
69
|
+
reverse (bool, optional): Whether to reverse the direction of the exchanges. Defaults to False.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
MFExchanges or exchanges_class: The exchanges associated with the activity.
|
|
73
|
+
"""
|
|
74
|
+
if exchanges_class is None:
|
|
75
|
+
return self._edges_class(self.key, kinds, reverse)
|
|
76
|
+
return exchanges_class(self.key, kinds, reverse)
|
|
77
|
+
|
|
78
|
+
def technosphere(self, exchanges_class=None):
|
|
79
|
+
"""
|
|
80
|
+
Retrieve technosphere exchanges associated with the activity.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
exchanges_class (type, optional): The class to use for exchanges. Defaults to None.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
MFExchanges or exchanges_class: The technosphere exchanges.
|
|
87
|
+
"""
|
|
88
|
+
return self.exchanges(exchanges_class, kinds=labels.technosphere_negative_edge_types)
|
|
89
|
+
|
|
90
|
+
def biosphere(self, exchanges_class=None):
|
|
91
|
+
"""
|
|
92
|
+
Retrieve biosphere exchanges associated with the activity.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
exchanges_class (type, optional): The class to use for exchanges. Defaults to None.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
MFExchanges or exchanges_class: The biosphere exchanges.
|
|
99
|
+
"""
|
|
100
|
+
return self.exchanges(exchanges_class, kinds=labels.biosphere_edge_types)
|
|
101
|
+
|
|
102
|
+
def production(self, include_substitution=False, exchanges_class=None):
|
|
103
|
+
"""
|
|
104
|
+
Retrieve production exchanges associated with the activity.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
include_substitution (bool, optional): Whether to include substitution exchanges. Defaults to False.
|
|
108
|
+
exchanges_class (type, optional): The class to use for exchanges. Defaults to None.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
MFExchanges or exchanges_class: The production exchanges.
|
|
112
|
+
"""
|
|
113
|
+
kinds = labels.technosphere_positive_edge_types
|
|
114
|
+
if not include_substitution:
|
|
115
|
+
kinds = [obj for obj in kinds if obj not in labels.substitution_edge_types]
|
|
116
|
+
|
|
117
|
+
return self.exchanges(exchanges_class, kinds=kinds)
|
|
118
|
+
|
|
119
|
+
def rp_exchange(self):
|
|
120
|
+
"""
|
|
121
|
+
Retrieve the reference product exchange.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
NotImplementedError: This method is not implemented.
|
|
125
|
+
"""
|
|
126
|
+
raise NotImplementedError
|
|
127
|
+
|
|
128
|
+
def substitution(self, exchanges_class=None):
|
|
129
|
+
"""
|
|
130
|
+
Retrieve substitution exchanges associated with the activity.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
exchanges_class (type, optional): The class to use for exchanges. Defaults to None.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
MFExchanges or exchanges_class: The substitution exchanges.
|
|
137
|
+
"""
|
|
138
|
+
return self.exchanges(exchanges_class, kinds=labels.substitution_edge_types)
|
|
139
|
+
|
|
140
|
+
def upstream(self, kinds=labels.technosphere_negative_edge_types, exchanges_class=None) -> MFExchanges:
|
|
141
|
+
"""
|
|
142
|
+
Retrieve upstream exchanges associated with the activity.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
kinds (list, optional): The types of upstream exchanges to retrieve. Defaults to technosphere negative edge types.
|
|
146
|
+
exchanges_class (type, optional): The class to use for exchanges. Defaults to None.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
MFExchanges: The upstream exchanges.
|
|
150
|
+
"""
|
|
151
|
+
return self.exchanges(exchanges_class, kinds=kinds, reverse=True)
|
|
152
|
+
|
|
153
|
+
def new_edge(self, **kwargs):
|
|
154
|
+
"""
|
|
155
|
+
Create a new exchange linked to this activity.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
**kwargs: Additional arguments for creating the exchange.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
MFExchange: The newly created exchange.
|
|
162
|
+
"""
|
|
163
|
+
exc = super().new_edge(**kwargs)
|
|
164
|
+
return self._edge_class(**exc)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Process(MFActivity):
|
|
168
|
+
"""
|
|
169
|
+
A class representing a process in the functional_sqlite backend.
|
|
170
|
+
|
|
171
|
+
This class extends the `MFActivity` class to provide additional functionality for managing processes,
|
|
172
|
+
including creating new products, reductions, and handling allocation strategies.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def save(self, signal: bool = True, data_already_set: bool = False, force_insert: bool = False):
|
|
176
|
+
"""
|
|
177
|
+
Save the process to the database.
|
|
178
|
+
|
|
179
|
+
This method determines the type and allocation strategy of the process before saving it.
|
|
180
|
+
If the allocation strategy changes, it triggers reallocation.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
signal (bool, optional): Whether to send a signal after saving. Defaults to True.
|
|
184
|
+
data_already_set (bool, optional): Whether the data is already set. Defaults to False.
|
|
185
|
+
force_insert (bool, optional): Whether to force an insert operation. Defaults to False.
|
|
186
|
+
"""
|
|
187
|
+
created = self.id is None
|
|
188
|
+
old = ActivityDataset.get_by_id(self.id) if not created else None
|
|
189
|
+
|
|
190
|
+
self["type"] = self.deduct_type()
|
|
191
|
+
self["allocation"] = self.get("allocation", databases[self["database"]].get("default_allocation"))
|
|
192
|
+
|
|
193
|
+
super().save(signal, data_already_set, force_insert)
|
|
194
|
+
|
|
195
|
+
if not created and old.data.get("allocation") != self.get("allocation"):
|
|
196
|
+
self.allocate()
|
|
197
|
+
|
|
198
|
+
def copy(self, *args, **kwargs):
|
|
199
|
+
"""
|
|
200
|
+
Create a copy of the process.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
*args: Positional arguments for the copy operation.
|
|
204
|
+
**kwargs: Keyword arguments for the copy operation.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Process: A copy of the process.
|
|
208
|
+
"""
|
|
209
|
+
act = super().copy(*args, **kwargs)
|
|
210
|
+
act.production().delete() # Delete the production exchanges to avoid duplicates
|
|
211
|
+
|
|
212
|
+
for product in self.products():
|
|
213
|
+
prod_copy = product.copy(processor=act.key)
|
|
214
|
+
|
|
215
|
+
edge_data = product.processing_edge.as_dict()
|
|
216
|
+
edge_data["input"] = prod_copy.key
|
|
217
|
+
edge_data.pop("output", None)
|
|
218
|
+
|
|
219
|
+
act.new_edge(**edge_data).save()
|
|
220
|
+
|
|
221
|
+
return self.__class__(document=act._document)
|
|
222
|
+
|
|
223
|
+
def deduct_type(self) -> str:
|
|
224
|
+
"""
|
|
225
|
+
Deduce the type of the process.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
str: The type of the process, which can be "multifunctional", "nonfunctional", or "process".
|
|
229
|
+
"""
|
|
230
|
+
if self.multifunctional:
|
|
231
|
+
return "multifunctional"
|
|
232
|
+
elif not self.functional:
|
|
233
|
+
return "nonfunctional"
|
|
234
|
+
else:
|
|
235
|
+
return "process"
|
|
236
|
+
|
|
237
|
+
def new_product(self, type="product", **kwargs):
|
|
238
|
+
"""
|
|
239
|
+
Create a new product associated with the process.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
**kwargs: Additional arguments for creating the product.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Product: A new product.
|
|
246
|
+
"""
|
|
247
|
+
kwargs["type"] = type
|
|
248
|
+
kwargs["processor"] = self.key
|
|
249
|
+
kwargs["database"] = self["database"]
|
|
250
|
+
kwargs["properties"] = {p: self.property_template(p) for p in self.available_properties()}
|
|
251
|
+
return Product(**kwargs)
|
|
252
|
+
|
|
253
|
+
def available_properties(self) -> set[str]:
|
|
254
|
+
"""
|
|
255
|
+
Retrieve the available properties for the process.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
set[str]: A list of property names available in the process.
|
|
259
|
+
"""
|
|
260
|
+
properties = [prod["properties"] for prod in self.products()]
|
|
261
|
+
property_names = [set(prop.keys()) for prop in properties]
|
|
262
|
+
common_properties = set.intersection(*property_names) if property_names else set()
|
|
263
|
+
|
|
264
|
+
return common_properties
|
|
265
|
+
|
|
266
|
+
def property_template(self, name: str, amount=1.0) -> dict:
|
|
267
|
+
"""
|
|
268
|
+
Create a property template for the process.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
name (str): The name of the property.
|
|
272
|
+
amount (float, optional): The amount of the property. Defaults to 1.0.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
dict: A dictionary representing the property template.
|
|
276
|
+
"""
|
|
277
|
+
properties = [prod["properties"][name] for prod in self.products() if name in prod["properties"]]
|
|
278
|
+
units = set(prop["unit"] for prop in properties)
|
|
279
|
+
normalize = set(prop.get("normalize", True) for prop in properties)
|
|
280
|
+
|
|
281
|
+
if len(units) > 1 or len(normalize) > 1:
|
|
282
|
+
log.warning(f"Property {name} has inconsistent units or normalization across products.")
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
"unit": units.pop() if units else "unitless",
|
|
286
|
+
"amount": amount,
|
|
287
|
+
"normalize": normalize.pop() if normalize else True
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
def products(self):
|
|
291
|
+
"""
|
|
292
|
+
Retrieve the products (products or wastes) associated with the process.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
list: A list of products associated with the process.
|
|
296
|
+
"""
|
|
297
|
+
excs = self.exchanges(kinds=["production"])
|
|
298
|
+
return [exc.input for exc in excs]
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def functional(self) -> bool:
|
|
302
|
+
"""
|
|
303
|
+
Check if the process is functional.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
bool: True if the process has at least one production exchange, False otherwise.
|
|
307
|
+
"""
|
|
308
|
+
return len(self.production()) > 0
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def multifunctional(self) -> bool:
|
|
312
|
+
"""
|
|
313
|
+
Check if the process is multifunctional.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
bool: True if the process has more than one production exchange, False otherwise.
|
|
317
|
+
"""
|
|
318
|
+
return len(self.production()) > 1
|
|
319
|
+
|
|
320
|
+
def allocate(self, strategy_label: Optional[str] = None) -> None:
|
|
321
|
+
"""
|
|
322
|
+
Allocate the process using the specified strategy.
|
|
323
|
+
|
|
324
|
+
This method applies the allocation strategy to the process. If no strategy is provided,
|
|
325
|
+
it uses the default allocation strategy from the process or database metadata.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
strategy_label (str, optional): The label of the allocation strategy. Defaults to None.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
None
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
ValueError: If no allocation strategy is found.
|
|
335
|
+
"""
|
|
336
|
+
if self.get("skip_allocation") or not self.multifunctional:
|
|
337
|
+
log.debug(f"Skipping allocation for {repr(self)} (id: {self.id})")
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
from . import allocation_strategies, property_allocation
|
|
341
|
+
|
|
342
|
+
if strategy_label is None:
|
|
343
|
+
if self.get("allocation"):
|
|
344
|
+
strategy_label = self.get("allocation")
|
|
345
|
+
else:
|
|
346
|
+
strategy_label = databases[self["database"]].get("default_allocation")
|
|
347
|
+
|
|
348
|
+
if not strategy_label:
|
|
349
|
+
raise ValueError(
|
|
350
|
+
"Can't find `default_allocation` in input arguments, or process/database metadata."
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
log.debug(f"Allocating {repr(self)} (id: {self.id}) with strategy {strategy_label}")
|
|
354
|
+
|
|
355
|
+
alloc_function = allocation_strategies.get(strategy_label, property_allocation(strategy_label))
|
|
356
|
+
alloc_function(self)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class Product(MFActivity):
|
|
360
|
+
"""
|
|
361
|
+
Represents a product that can be either a 'product' or 'waste'.
|
|
362
|
+
|
|
363
|
+
Products should always have a `processor` key set, which is the process handling the product.
|
|
364
|
+
|
|
365
|
+
This class extends `MFActivity` and provides additional functionality for managing
|
|
366
|
+
products, including saving, deleting, and validating them, as well as handling
|
|
367
|
+
processing edges and substitution.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
def save(self, signal: bool = True, data_already_set: bool = False, force_insert: bool = False):
|
|
371
|
+
"""
|
|
372
|
+
Save the product to the database.
|
|
373
|
+
|
|
374
|
+
This method validates the product before saving, determines its type (product or waste),
|
|
375
|
+
and handles changes to the processor, allocation properties, and substitution factors.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
signal (bool, optional): Whether to send a signal after saving. Defaults to True.
|
|
379
|
+
data_already_set (bool, optional): Whether the data is already set. Defaults to False.
|
|
380
|
+
force_insert (bool, optional): Whether to force an insert operation. Defaults to False.
|
|
381
|
+
|
|
382
|
+
Raises:
|
|
383
|
+
ValidityError: If the product is not valid.
|
|
384
|
+
"""
|
|
385
|
+
if not self.valid():
|
|
386
|
+
raise ValidityError(
|
|
387
|
+
"This activity can't be saved for the "
|
|
388
|
+
+ "following reasons\n\t* "
|
|
389
|
+
+ "\n\t* ".join(self.valid(why=True)[1])
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
created = self.id is None
|
|
393
|
+
old = ActivityDataset.get_by_id(self.id) if not created else None
|
|
394
|
+
|
|
395
|
+
if not created:
|
|
396
|
+
self["type"] = self.deduct_type() # make sure the type is set correctly
|
|
397
|
+
|
|
398
|
+
super().save(signal, data_already_set, force_insert)
|
|
399
|
+
|
|
400
|
+
edge = self.processing_edge
|
|
401
|
+
|
|
402
|
+
# Check if the `processor` is the same as the one in the production edge otherwise update it
|
|
403
|
+
if not created and edge.output != self["processor"]:
|
|
404
|
+
log.info(f"Switching processor for {self}")
|
|
405
|
+
edge.output = self["processor"]
|
|
406
|
+
edge.save()
|
|
407
|
+
|
|
408
|
+
# Check if the property used for allocation has changed and allocate if necessary
|
|
409
|
+
if (not created and
|
|
410
|
+
old.data.get("properties", {}).get(self.processor.get("allocation")) !=
|
|
411
|
+
self.get("properties", {}).get(self.processor.get("allocation"))):
|
|
412
|
+
self.processor.allocate()
|
|
413
|
+
|
|
414
|
+
# Check if the substitution factor has changed and allocate if necessary
|
|
415
|
+
# if not created and (old.data.get("substitution_factor", 0) > 0) != (self.get("substitution_factor", 0) > 0):
|
|
416
|
+
# self.processor.allocate()
|
|
417
|
+
|
|
418
|
+
# If the product is new and there's no production exchange yet, create one
|
|
419
|
+
if created and not edge:
|
|
420
|
+
self.create_processing_edge()
|
|
421
|
+
|
|
422
|
+
# If the product is new and has a processing edge, allocate the processor
|
|
423
|
+
if created and edge and isinstance(edge.output, Process):
|
|
424
|
+
edge.output.allocate()
|
|
425
|
+
|
|
426
|
+
def deduct_type(self) -> str:
|
|
427
|
+
"""
|
|
428
|
+
Deduce the type of the product.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
str: The type of the product, which can be 'product', 'waste', or 'orphaned_product'.
|
|
432
|
+
"""
|
|
433
|
+
edge = self.processing_edge
|
|
434
|
+
if not edge:
|
|
435
|
+
return "orphaned_product"
|
|
436
|
+
elif edge.amount < 0:
|
|
437
|
+
return "waste"
|
|
438
|
+
else:
|
|
439
|
+
return "product"
|
|
440
|
+
|
|
441
|
+
def delete(self, signal: bool = True):
|
|
442
|
+
"""
|
|
443
|
+
Delete the product and its upstream production exchanges.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
signal (bool, optional): Whether to send a signal after deletion. Defaults to True.
|
|
447
|
+
"""
|
|
448
|
+
# Delete the product by deleting its production exchange. This will make sure there's no infinite loop
|
|
449
|
+
self.upstream(["production"]).delete()
|
|
450
|
+
|
|
451
|
+
@property
|
|
452
|
+
def processing_edge(self) -> MFExchange | None:
|
|
453
|
+
"""
|
|
454
|
+
Retrieve the processing edge of the product.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
MFExchange or None: The processing edge if it exists, otherwise None.
|
|
458
|
+
|
|
459
|
+
Raises:
|
|
460
|
+
ValidityError: If the product has multiple processing edges.
|
|
461
|
+
"""
|
|
462
|
+
excs = self.exchanges(kinds=["production"], reverse=True)
|
|
463
|
+
|
|
464
|
+
if len(excs) > 1:
|
|
465
|
+
log.warning(f"Multiple processing edges found for product {self['code']}.")
|
|
466
|
+
return None
|
|
467
|
+
if len(excs) == 0:
|
|
468
|
+
return None
|
|
469
|
+
return list(excs)[0]
|
|
470
|
+
|
|
471
|
+
def create_processing_edge(self, amount: float = None):
|
|
472
|
+
"""
|
|
473
|
+
Create a new processing edge for the product.
|
|
474
|
+
"""
|
|
475
|
+
if amount is None:
|
|
476
|
+
amount = 1.0 if self["type"] == "product" else -1.0
|
|
477
|
+
MFExchange(input=self.key, output=self["processor"], amount=amount, type="production").save()
|
|
478
|
+
|
|
479
|
+
@property
|
|
480
|
+
def processor(self) -> Process | None:
|
|
481
|
+
"""
|
|
482
|
+
Retrieve the processor (process) associated with the product. If no processor key is set, will try to deduct
|
|
483
|
+
the processor from the production edge and set the processor key afterwards.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Process or None: The associated process, or None if not found.
|
|
487
|
+
"""
|
|
488
|
+
if key := self.get("processor"):
|
|
489
|
+
return get_node(key=key)
|
|
490
|
+
|
|
491
|
+
edge = self.processing_edge
|
|
492
|
+
if not edge:
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
processor = edge.output
|
|
496
|
+
self["processor"] = processor.key
|
|
497
|
+
return processor
|
|
498
|
+
|
|
499
|
+
@property
|
|
500
|
+
def virtual_edges(self) -> list[dict]:
|
|
501
|
+
"""
|
|
502
|
+
Generate virtual edges for the product.
|
|
503
|
+
|
|
504
|
+
Virtual edges are created based on the allocation factor and include technosphere,
|
|
505
|
+
biosphere, and production exchanges.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
list[dict]: A list of dictionaries representing the virtual edges.
|
|
509
|
+
"""
|
|
510
|
+
virtual_exchanges = []
|
|
511
|
+
for exchange in self._edges_class(self.processor.key, ["technosphere", "biosphere"]):
|
|
512
|
+
ds = exchange.as_dict()
|
|
513
|
+
ds["amount"] = ds["amount"] * self.get("allocation_factor", 1)
|
|
514
|
+
ds["output"] = self.key
|
|
515
|
+
virtual_exchanges.append(ds)
|
|
516
|
+
|
|
517
|
+
production = self.processing_edge.as_dict()
|
|
518
|
+
production["output"] = self.key
|
|
519
|
+
|
|
520
|
+
return virtual_exchanges
|
|
521
|
+
|
|
522
|
+
# def substitute(self, substitute_key: tuple | None = None, substitution_factor=1.0):
|
|
523
|
+
# """
|
|
524
|
+
# Set or remove substitution for the product.
|
|
525
|
+
#
|
|
526
|
+
# Args:
|
|
527
|
+
# substitute_key (tuple, optional): The key of the substitute. Defaults to None.
|
|
528
|
+
# substitution_factor (float, optional): The substitution factor. Defaults to 1.0.
|
|
529
|
+
# """
|
|
530
|
+
# if substitute_key is None:
|
|
531
|
+
# if self.get("substitute"):
|
|
532
|
+
# del self["substitute"]
|
|
533
|
+
# if self.get("substitution_factor"):
|
|
534
|
+
# del self["substitution_factor"]
|
|
535
|
+
# return
|
|
536
|
+
#
|
|
537
|
+
# self["substitute"] = substitute_key
|
|
538
|
+
# self["substitution_factor"] = substitution_factor
|
|
539
|
+
|
|
540
|
+
def new_edge(self, **kwargs):
|
|
541
|
+
"""
|
|
542
|
+
Create a new edge for the product.
|
|
543
|
+
|
|
544
|
+
Raises:
|
|
545
|
+
NotImplementedError: Products cannot have input edges.
|
|
546
|
+
"""
|
|
547
|
+
raise NotImplementedError("Products cannot have input edges")
|
|
548
|
+
|
|
549
|
+
def valid(self, why=False):
|
|
550
|
+
"""
|
|
551
|
+
Validate the product.
|
|
552
|
+
|
|
553
|
+
A `Product` is considered valid if:
|
|
554
|
+
- It has a `processor` key that is a tuple and corresponds to an existing process node.
|
|
555
|
+
- It has a `type` field, which must be either "product" or "waste".
|
|
556
|
+
- It passes the validation checks of the parent `MFActivity` class.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
why (bool, optional): Whether to return the reasons for invalidity. Defaults to False.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
bool or tuple: True if valid, otherwise False or a tuple with reasons for invalidity.
|
|
563
|
+
"""
|
|
564
|
+
if super().valid():
|
|
565
|
+
errors = []
|
|
566
|
+
else:
|
|
567
|
+
_, errors = super().valid(why=True)
|
|
568
|
+
|
|
569
|
+
if not self.get("processor") and self.processor is None:
|
|
570
|
+
errors.append("Missing field ``processor``")
|
|
571
|
+
elif not isinstance(self["processor"], tuple):
|
|
572
|
+
errors.append("Field ``processor`` must be a tuple")
|
|
573
|
+
else:
|
|
574
|
+
try:
|
|
575
|
+
get_node(key=self.get("processor"))
|
|
576
|
+
except UnknownObject:
|
|
577
|
+
errors.append("Processor node not found")
|
|
578
|
+
|
|
579
|
+
if not self.get("type"):
|
|
580
|
+
errors.append("Missing field ``type``, product most be ``product`` or ``waste``")
|
|
581
|
+
elif self["type"] not in ["product", "waste", "orphaned_product"]:
|
|
582
|
+
errors.append("Product ``type`` most be ``product`` or ``waste``")
|
|
583
|
+
|
|
584
|
+
if errors:
|
|
585
|
+
if why:
|
|
586
|
+
return (False, errors)
|
|
587
|
+
else:
|
|
588
|
+
return False
|
|
589
|
+
else:
|
|
590
|
+
return True
|
|
591
|
+
|