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.
@@ -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
+