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,514 @@
1
+ import sqlite3
2
+ import pickle
3
+ import datetime
4
+ from logging import getLogger
5
+ from time import time
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from fsspec.implementations.zip import ZipFileSystem
10
+
11
+ import stats_arrays as sa
12
+
13
+ from bw_processing import clean_datapackage_name, create_datapackage
14
+ from bw2data.backends import SQLiteBackend, sqlite3_lci_db
15
+ from bw2data.backends.schema import ActivityDataset
16
+ from pandas._libs.missing import NAType
17
+
18
+ from .node_classes import Process, Product
19
+
20
+ log = getLogger(__name__)
21
+
22
+ UNCERTAINTY_FIELDS = ["uncertainty_type", "loc", "scale", "shape", "minimum", "maximum"]
23
+
24
+
25
+ class FunctionalSQLiteDatabase(SQLiteBackend):
26
+ """
27
+ A specialized SQLite backend for handling functional databases.
28
+
29
+ This class extends the `SQLiteBackend` to provide additional functionality for
30
+ processing and managing processes with one or more functions, including relabeling data, registering
31
+ metadata, and processing data into a structured format..
32
+ """
33
+
34
+ backend = "functional_sqlite"
35
+
36
+ @staticmethod
37
+ def node_class(document: ActivityDataset = None) -> Process | Product:
38
+ """Dispatch the correct node class depending on document attributes."""
39
+ if document and document.type in ["product", "waste"]:
40
+ return Product(document=document)
41
+ else:
42
+ return Process(document=document)
43
+
44
+ @staticmethod
45
+ def relabel_data(data: dict, old_name: str, new_name: str) -> dict:
46
+ """
47
+ Relabels data to update references from an old database name to a new one.
48
+
49
+ This method updates the `input`, `output`, and `processor` fields in the data
50
+ dictionary to reflect the new database name.
51
+
52
+ Args:
53
+ data (dict): The data dictionary containing activity and exchange information.
54
+ old_name (str): The old database name to be replaced.
55
+ new_name (str): The new database name to replace the old one.
56
+
57
+ Returns:
58
+ dict: A dictionary with updated references to the new database name.
59
+ """
60
+
61
+ def relabel_exchanges(obj: dict, old_name: str, new_name: str) -> dict:
62
+ """
63
+ Updates the `input`, `output`, and `processor` fields in an exchange object.
64
+
65
+ Args:
66
+ obj (dict): The exchange object to update.
67
+ old_name (str): The old database name to be replaced.
68
+ new_name (str): The new database name to replace the old one.
69
+
70
+ Returns:
71
+ dict: The updated exchange object.
72
+ """
73
+ for e in obj.get("exchanges", []):
74
+ if "input" in e and e["input"][0] == old_name:
75
+ e["input"] = (new_name, e["input"][1])
76
+ if "output" in e and e["output"][0] == old_name:
77
+ e["output"] = (new_name, e["output"][1])
78
+
79
+ if obj.get("processor") and obj.get("processor")[0] == old_name:
80
+ obj["processor"] = (new_name, obj["processor"][1])
81
+
82
+ return obj
83
+
84
+ return dict(
85
+ [((new_name, code), relabel_exchanges(act, old_name, new_name)) for (db, code), act in data.items()]
86
+ )
87
+
88
+ def register(self, **kwargs):
89
+ """
90
+ Registers the database with default metadata.
91
+
92
+ This method ensures that the `default_allocation` key is set to "equal" if not
93
+ provided in the keyword arguments, and then calls the parent class's `register` method.
94
+
95
+ Args:
96
+ **kwargs: Additional metadata to register with the database.
97
+ """
98
+ if "default_allocation" not in kwargs:
99
+ kwargs["default_allocation"] = "equal"
100
+ super().register(**kwargs)
101
+
102
+ def process(self, csv: bool = False, allocate: bool = True) -> None:
103
+ """
104
+ Processes the database to generate structured data tables and metadata.
105
+
106
+ This method retrieves data from the database, processes it into technosphere
107
+ and biosphere matrices, and serializes the results into a datapackage.
108
+
109
+ Args:
110
+ csv (bool, optional): Whether to output the results as CSV files. Defaults to False.
111
+ allocate (bool, optional): Whether to perform allocation during processing. Defaults to True.
112
+ """
113
+ nodes, exchanges, dependents = self.get_tables()
114
+ exchanges = Mutate.set_default_uncertainty_values(exchanges)
115
+
116
+ tech_matrix = Build.technosphere(nodes, exchanges)
117
+ bio_matrix = Build.biosphere(nodes, exchanges)
118
+
119
+ self.metadata["processed"] = datetime.datetime.now().isoformat()
120
+
121
+ fp = str(self.dirpath_processed() / self.filename_processed())
122
+
123
+ dp = create_datapackage(
124
+ fs=ZipFileSystem(fp, mode="w"),
125
+ name=clean_datapackage_name(self.name),
126
+ sum_intra_duplicates=True,
127
+ sum_inter_duplicates=False,
128
+ )
129
+ self._add_inventory_geomapping_to_datapackage(dp)
130
+
131
+ dp.add_persistent_vector_from_iterator(
132
+ matrix="biosphere_matrix",
133
+ name=clean_datapackage_name(self.name + " biosphere matrix"),
134
+ dict_iterator=bio_matrix.to_dict('records'),
135
+ )
136
+
137
+ dp.add_persistent_vector_from_iterator(
138
+ matrix="technosphere_matrix",
139
+ name=clean_datapackage_name(self.name + " technosphere matrix"),
140
+ dict_iterator=tech_matrix.to_dict('records'),
141
+ )
142
+
143
+ dp.finalize_serialization()
144
+
145
+ self.metadata["depends"] = list(dependents)
146
+ self.metadata["dirty"] = False
147
+ self._metadata.flush()
148
+
149
+ def get_tables(self) -> (pd.DataFrame, pd.DataFrame, set):
150
+ """
151
+ Retrieves and processes data tables from the SQLite database.
152
+
153
+ This method extracts node and exchange data from the database, maps IDs to
154
+ their corresponding keys, and identifies dependent databases.
155
+
156
+ Returns:
157
+ tuple: A tuple containing:
158
+ - pd.DataFrame: The node data.
159
+ - pd.DataFrame: The exchange data.
160
+ - set: A set of dependent database names.
161
+ """
162
+ t = time()
163
+ con = sqlite3.connect(sqlite3_lci_db._filepath)
164
+
165
+ def id_mapper(key) -> NAType | int:
166
+ """
167
+ Maps a key to its corresponding ID.
168
+
169
+ Args:
170
+ key: The key to map.
171
+
172
+ Returns:
173
+ NAType | int: The mapped ID or NA if the key is invalid.
174
+
175
+ Raises:
176
+ KeyError: If the key is not found in the mapping dictionary.
177
+ """
178
+ if not isinstance(key, tuple):
179
+ return pd.NA
180
+ try:
181
+ return id_map_dict[key]
182
+ except KeyError:
183
+ raise KeyError(f"Node key {key} not found.")
184
+
185
+ # Retrieve the mapping of IDs to database and code from the `activitydataset` table
186
+ id_map = pd.read_sql(f"SELECT id, database, code FROM activitydataset", con)
187
+
188
+ # Create a new column `key` by combining `database` and `code` into a tuple
189
+ id_map["key"] = id_map.loc[:, ["database", "code"]].apply(tuple, axis=1)
190
+
191
+ # Convert the mapping of `key` to `id` into a dictionary for quick lookups
192
+ id_map_dict = id_map.set_index("key")["id"].to_dict()
193
+
194
+ # Retrieve raw data from the `activitydataset` table for the current database
195
+ raw = pd.read_sql(f"SELECT data FROM activitydataset WHERE database = '{self.name}'", con)
196
+
197
+ # Deserialize the raw data using `pickle` and create a DataFrame with specified columns
198
+ node_df = pd.DataFrame([pickle.loads(x) for x in raw["data"]],
199
+ columns=["database", "code", "type", "processor", "allocation_factor", "substitute",
200
+ "substitution_factor"])
201
+
202
+ # Merge the `node_df` with the `id_map` to include the `id` column
203
+ node_df = node_df.merge(id_map[["database", "code", "id"]], on=["database", "code"])
204
+
205
+ # Map the `processor` and `substitute` columns to their corresponding IDs using `id_mapper`
206
+ node_df["processor"] = node_df["processor"].map(id_mapper).astype("Int64")
207
+ node_df["substitute"] = node_df["substitute"].map(id_mapper).astype("Int64")
208
+
209
+ # Select and reorder the relevant columns for the final `node_df`
210
+ node_df = node_df[["id", "type", "processor", "allocation_factor", "substitute", "substitution_factor"]]
211
+
212
+ # Retrieve raw data from the `exchangedataset` table for the current database
213
+ raw = pd.read_sql(f"SELECT data, input_database FROM exchangedataset WHERE output_database = '{self.name}'",
214
+ con)
215
+
216
+ # Deserialize the raw data using `pickle` and create a DataFrame with specified columns
217
+ exc_df = pd.DataFrame([pickle.loads(x) for x in raw["data"]],
218
+ columns=["input", "output", "type", "amount", "uncertainty type"] + UNCERTAINTY_FIELDS)
219
+
220
+ # Update the `uncertainty_type` column to ensure consistency
221
+ exc_df.update(exc_df["uncertainty_type"].rename("uncertainty type"))
222
+ exc_df["uncertainty_type"] = exc_df["uncertainty type"]
223
+
224
+ # Drop the redundant `uncertainty type` column
225
+ exc_df.drop(["uncertainty type"], axis=1, inplace=True)
226
+
227
+ # Map the `input` and `output` columns to their corresponding IDs using `id_mapper`
228
+ exc_df["input"] = exc_df["input"].map(id_mapper).astype("Int64")
229
+ exc_df["output"] = exc_df["output"].map(id_mapper).astype("Int64")
230
+
231
+ # Identify dependent databases by extracting unique values from the `input_database` column
232
+ dependents = set(raw["input_database"].unique())
233
+
234
+ # Remove the current database name from the set of dependents, if present
235
+ if self.name in dependents:
236
+ dependents.remove(self.name)
237
+
238
+ # Close the SQLite connection
239
+ con.close()
240
+
241
+ log.debug(f"Processing: built tables from SQL in {time() - t:.2f} seconds")
242
+
243
+ return node_df, exc_df, dependents
244
+
245
+
246
+ class Build:
247
+ """
248
+ A utility class for constructing technosphere and biosphere matrices from nodes and exchanges.
249
+
250
+ This class provides static methods to process and allocate data into structured formats
251
+ for use in life cycle assessment (LCA) models. It includes methods for handling technosphere
252
+ and biosphere exchanges, as well as consumption and production flows.
253
+ """
254
+
255
+ @staticmethod
256
+ def technosphere(nodes, exchanges):
257
+ """
258
+ Constructs the technosphere matrix by combining consumption and production flows.
259
+
260
+ Args:
261
+ nodes (pd.DataFrame): The dataframe containing node data.
262
+ exchanges (pd.DataFrame): The dataframe containing exchange data.
263
+
264
+ Returns:
265
+ pd.DataFrame: A concatenated dataframe of consumption and production flows.
266
+ """
267
+ consumption = Build.consumption(nodes, exchanges)
268
+ production = Build.production(nodes, exchanges)
269
+ return pd.concat([consumption, production])
270
+
271
+ @staticmethod
272
+ def biosphere(nodes, exchanges):
273
+ """
274
+ Constructs the biosphere matrix by allocating biosphere exchanges.
275
+
276
+ Args:
277
+ nodes (pd.DataFrame): The dataframe containing node data.
278
+ exchanges (pd.DataFrame): The dataframe containing exchange data.
279
+
280
+ Returns:
281
+ pd.DataFrame: A dataframe containing allocated biosphere exchanges with additional metadata.
282
+ """
283
+ x = Build.allocated(nodes, exchanges, ["biosphere"])
284
+ x["flip"] = False
285
+ return x[["row", "col", "amount", "flip"] + UNCERTAINTY_FIELDS]
286
+
287
+ @staticmethod
288
+ def consumption(nodes, exchanges):
289
+ """
290
+ Constructs the consumption flows by allocating technosphere exchanges.
291
+
292
+ Args:
293
+ nodes (pd.DataFrame): The dataframe containing node data.
294
+ exchanges (pd.DataFrame): The dataframe containing exchange data.
295
+
296
+ Returns:
297
+ pd.DataFrame: A dataframe containing allocated consumption flows with additional metadata.
298
+ """
299
+ x = Build.allocated(nodes, exchanges, ["technosphere"])
300
+ x["flip"] = True
301
+ return x[["row", "col", "amount", "flip"] + UNCERTAINTY_FIELDS]
302
+
303
+ @staticmethod
304
+ def production(nodes, exchanges):
305
+ """
306
+ Constructs the production flows by joining production exchanges to their respective functions.
307
+
308
+ Args:
309
+ nodes (pd.DataFrame): The dataframe containing node data.
310
+ exchanges (pd.DataFrame): The dataframe containing exchange data.
311
+
312
+ Returns:
313
+ pd.DataFrame: A dataframe containing production flows with additional metadata.
314
+ """
315
+ x = Join.production_exchanges_to_functions(nodes, exchanges)
316
+
317
+ x["flip"] = False
318
+ x.rename(columns={"input": "row", "output": "col"}, inplace=True)
319
+
320
+ return x[["row", "col", "amount", "flip"] + UNCERTAINTY_FIELDS]
321
+
322
+ @staticmethod
323
+ def allocated(nodes, exchanges, exchange_types):
324
+ """
325
+ Allocates exchanges of specified types to their respective functions.
326
+
327
+ Args:
328
+ nodes (pd.DataFrame): The dataframe containing node data.
329
+ exchanges (pd.DataFrame): The dataframe containing exchange data.
330
+ exchange_types (list or tuple): The types of exchanges to allocate (e.g., "biosphere", "technosphere").
331
+
332
+ Returns:
333
+ pd.DataFrame: A dataframe containing allocated exchanges with additional metadata.
334
+ """
335
+ x = Join.exchanges_to_functions(nodes, exchanges, exchange_types)
336
+ x = Mutate.allocate_amount(x)
337
+ x = Mutate.allocate_distributions(x)
338
+ x.rename(columns={"input": "row", "output": "col"}, inplace=True)
339
+
340
+ return x[["row", "col", "amount"] + UNCERTAINTY_FIELDS]
341
+
342
+
343
+ class Mutate:
344
+ """
345
+ A utility class for mutating dataframes in the `bw_functional` framework.
346
+ This class provides methods to allocate amounts and distributions,
347
+ and set default uncertainty values for exchanges.
348
+ It is used to process and modify dataframes containing exchange and node information.
349
+ """
350
+ @staticmethod
351
+ def allocate_amount(df: pd.DataFrame) -> pd.DataFrame:
352
+ """
353
+ Allocate amounts for non-functional exchanges.
354
+
355
+ This method multiplies the `amount` column by the `allocation_factor` column for each row in the dataframe.
356
+ If the `allocation_factor` is not defined (NaN), it defaults to 1.
357
+
358
+ Args:
359
+ df (pd.DataFrame): A dataframe containing at least `amount` and `allocation_factor` columns.
360
+
361
+ Returns:
362
+ pd.DataFrame: The updated dataframe with allocated amounts in the `amount` column.
363
+ """
364
+ df["amount"] = df["allocation_factor"].fillna(1) * df["amount"]
365
+ return df
366
+
367
+ @staticmethod
368
+ def allocate_distributions(df: pd.DataFrame) -> pd.DataFrame:
369
+ """
370
+ Allocate uncertainty distributions for process-functions.
371
+
372
+ This method adjusts uncertainty distribution parameters (e.g., `loc`, `scale`, `minimum`, `maximum`)
373
+ based on the `allocation_factor`. It supports standard distributions and lognormal distributions.
374
+ Unsupported distributions with non-default allocation factors will trigger a warning.
375
+
376
+ Args:
377
+ df (pd.DataFrame): A dataframe containing uncertainty columns (`loc`, `scale`, etc.) and
378
+ an `allocation_factor` column.
379
+
380
+ Returns:
381
+ pd.DataFrame: The updated dataframe with allocated uncertainty distributions.
382
+
383
+ Notes:
384
+ - Standard distributions are adjusted by multiplying their parameters by the `allocation_factor`.
385
+ - Lognormal distributions adjust the `loc` parameter by adding the natural logarithm of the
386
+ `allocation_factor`.
387
+ - Unsupported distributions include Bernoulli, Discrete Uniform, Beta, and Student's T.
388
+ """
389
+ # Distributions that use the standard method
390
+ standard = [sa.NormalUncertainty.id, sa.UniformUncertainty.id, sa.TriangularUncertainty.id,
391
+ sa.WeibullUncertainty.id, sa.GammaUncertainty.id, sa.GeneralizedExtremeValueUncertainty.id,
392
+ sa.UndefinedUncertainty, sa.NoUncertainty]
393
+ # Lognormal uncertainty
394
+ ln = [sa.LognormalUncertainty.id]
395
+
396
+ # Identify unsupported distributions with non-default allocation factors
397
+ labels = (
398
+ (~df["uncertainty_type"].isin(standard + ln)) &
399
+ (df["allocation_factor"].fillna(1) != 1).any(axis=None)
400
+ )
401
+ if pd.Series.any(labels):
402
+ log.warning("Database contains distributions that cannot be allocated")
403
+
404
+ # Apply the standard method to applicable distributions
405
+ labels = df["uncertainty_type"].isin(standard)
406
+ df.loc[labels, "loc"] = df.loc[labels, "loc"] * df.loc[labels, "allocation_factor"].fillna(1)
407
+ df.loc[labels, "scale"] = df.loc[labels, "scale"] * df.loc[labels, "allocation_factor"].fillna(1)
408
+ df.loc[labels, "minimum"] = df.loc[labels, "minimum"] * df.loc[labels, "allocation_factor"].fillna(1)
409
+ df.loc[labels, "maximum"] = df.loc[labels, "maximum"] * df.loc[labels, "allocation_factor"].fillna(1)
410
+
411
+ # Apply the lognormal method to lognormal distributions
412
+ labels = df["uncertainty_type"].isin(ln)
413
+ df.loc[labels, "loc"] = df.loc[labels, "loc"] + np.log(df.loc[labels, "allocation_factor"].fillna(1))
414
+
415
+ return df
416
+
417
+ @staticmethod
418
+ def set_default_uncertainty_values(df: pd.DataFrame) -> pd.DataFrame:
419
+ """
420
+ Set default uncertainty values for exchanges.
421
+
422
+ This method ensures that all exchanges have a valid uncertainty type. For exchanges without
423
+ defined uncertainty, it sets the type to `UndefinedUncertainty` and assigns the `amount` value
424
+ to the `loc` parameter.
425
+
426
+ Args:
427
+ df (pd.DataFrame): A dataframe containing uncertainty columns (`uncertainty_type`, `loc`, etc.)
428
+ and an `amount` column.
429
+
430
+ Returns:
431
+ pd.DataFrame: The updated dataframe with default uncertainty values set.
432
+ """
433
+ # Set all undefined uncertainties to the UndefinedUncertainty type
434
+ df["uncertainty_type"] = df["uncertainty_type"].fillna(sa.UndefinedUncertainty.id)
435
+
436
+ # Identify rows with UndefinedUncertainty or NoUncertainty and missing `loc`
437
+ labels = (
438
+ df["uncertainty_type"].isin([sa.UndefinedUncertainty.id, sa.NoUncertainty.id]) &
439
+ (df["loc"].isna())
440
+ )
441
+ # Replace `loc` with the exchange amount
442
+ df.loc[labels, "loc"] = df.loc[labels, "amount"]
443
+ return df
444
+
445
+
446
+ class Join:
447
+ """
448
+ Utility class for joining nodes and exchanges in the `bw_functional` framework.
449
+
450
+ This class provides methods to create a square matrix by using function IDs as row and column indexes.
451
+ Since all exchanges of a process must be bound to its functions, the methods in this class join the
452
+ `nodes` and `exchanges` dataframes based on the `processor` of the node and the `output` of the exchange.
453
+ """
454
+
455
+ @staticmethod
456
+ def exchanges_to_functions(
457
+ nodes: pd.DataFrame,
458
+ exchanges: pd.DataFrame,
459
+ exchange_types: tuple | list,
460
+ keep=("allocation_factor",)
461
+ ) -> pd.DataFrame:
462
+ """
463
+ Joins exchanges of specified types from processes to all the functions of the processes.
464
+
465
+ This method filters exchanges by their type, then joins them with the functions of the processes
466
+ based on the `processor` field in the `nodes` dataframe and the `output` field in the `exchanges` dataframe.
467
+
468
+ Args:
469
+ nodes (pd.DataFrame): The dataframe containing node data, including the `processor` field.
470
+ exchanges (pd.DataFrame): The dataframe containing exchange data, including the `type` and `output` fields.
471
+ exchange_types (tuple | list): The types of exchanges to include in the join (e.g., "biosphere", "technosphere").
472
+ keep (tuple, optional): Additional columns from the `exchanges` dataframe to retain in the result. Defaults to ("allocation_factor",).
473
+
474
+ Returns:
475
+ pd.DataFrame: A dataframe containing the joined data, with the `id` column from `nodes` renamed to `output`.
476
+ """
477
+ exchanges = exchanges.loc[exchanges["type"].isin(exchange_types)]
478
+ functions = nodes.dropna(subset="processor").drop("type", axis=1)
479
+
480
+ joined = functions.merge(exchanges, left_on="processor", right_on="output")
481
+ joined = joined.drop(["processor", "output"], axis=1)
482
+ joined = joined.rename(columns={"id": "output"})
483
+
484
+ return joined[list(exchanges.columns) + list(keep)]
485
+
486
+ @staticmethod
487
+ def production_exchanges_to_functions(
488
+ nodes: pd.DataFrame,
489
+ exchanges: pd.DataFrame,
490
+ keep=()
491
+ ) -> pd.DataFrame:
492
+ """
493
+ Joins production exchanges from processes to the functions they belong to.
494
+
495
+ This method filters exchanges of type `production`, then joins them with the functions of the processes
496
+ based on the `id` and `processor` fields in the `nodes` dataframe and the `input` and `output` fields
497
+ in the `exchanges` dataframe.
498
+
499
+ Args:
500
+ nodes (pd.DataFrame): The dataframe containing node data, including the `id` and `processor` fields.
501
+ exchanges (pd.DataFrame): The dataframe containing exchange data, including the `type`, `input`, and `output` fields.
502
+ keep (tuple, optional): Additional columns from the `exchanges` dataframe to retain in the result. Defaults to an empty tuple.
503
+
504
+ Returns:
505
+ pd.DataFrame: A dataframe containing the joined data, with the `id` column from `nodes` renamed to `output`.
506
+ """
507
+ production_exchanges = exchanges.loc[exchanges["type"] == "production"]
508
+ functions = nodes.dropna(subset="processor").drop("type", axis=1)
509
+
510
+ joined = functions.merge(production_exchanges, left_on=["id", "processor"], right_on=["input", "output"])
511
+ joined = joined.drop(["processor", "output"], axis=1)
512
+ joined = joined.rename(columns={"id": "output"})
513
+
514
+ return joined[list(exchanges.columns) + list(keep)]
@@ -0,0 +1,166 @@
1
+ from logging import getLogger
2
+ from copy import deepcopy
3
+
4
+ from bw2data import projects, databases
5
+ from bw2data.backends.proxies import Exchange, Exchanges, ExchangeDataset
6
+
7
+ log = getLogger(__name__)
8
+
9
+
10
+ class MFExchanges(Exchanges):
11
+ """
12
+ A specialized class for managing exchanges of multifunctional processes.
13
+
14
+ This class extends the `Exchanges` class to provide additional functionality for
15
+ deleting and iterating over exchanges in the context of multifunctional processes.
16
+ """
17
+
18
+ def delete(self, allow_in_sourced_project: bool = False):
19
+ """
20
+ Deletes all exchanges in the current collection.
21
+
22
+ Delete exchanges by calling their methods instead of deleting them en masse. This enables enforcement of
23
+ allocation rules.
24
+
25
+ Args:
26
+ allow_in_sourced_project (bool, optional): Whether to allow deletion in sourced projects.
27
+ Defaults to False.
28
+
29
+ Raises:
30
+ NotImplementedError: If mass deletion is attempted in a sourced project without explicit permission.
31
+ """
32
+ if projects.dataset.is_sourced and not allow_in_sourced_project:
33
+ raise NotImplementedError("Mass exchange deletion not supported in sourced projects")
34
+ databases.set_dirty(self._key[0])
35
+ for exchange in self:
36
+ exchange.delete()
37
+
38
+ def __iter__(self):
39
+ """
40
+ Iterates over the exchanges in the current collection.
41
+
42
+ This method retrieves the queryset of exchanges and yields each one as an `MFExchange` object.
43
+
44
+ Yields:
45
+ MFExchange: An instance of the `MFExchange` class for each exchange in the collection.
46
+ """
47
+ for obj in self._get_queryset():
48
+ yield MFExchange(obj)
49
+
50
+
51
+ class MFExchange(Exchange):
52
+ """
53
+ A class representing exchanges from the functional_sqlite backend.
54
+
55
+ This class extends the `Exchange` class to provide additional functionality for handling
56
+ multifunctional processes, including generating virtual edges, saving, and deleting exchanges.
57
+ """
58
+
59
+ @property
60
+ def virtual_edges(self) -> list[dict]:
61
+ """
62
+ Generate virtual edges for the exchange.
63
+
64
+ Virtual edges are created based on the allocation factors of the functions associated
65
+ with the output process. For production exchanges, a single virtual edge is created
66
+ where the output is set to the input.
67
+
68
+ Returns:
69
+ list[dict]: A list of dictionaries representing the virtual edges.
70
+
71
+ Raises:
72
+ ValueError: If the output is not an instance of the `Process` class.
73
+ """
74
+ from .node_classes import Process, Product
75
+ edges = []
76
+
77
+ if self["type"] == "production":
78
+ ds = self.as_dict()
79
+ ds["output"] = ds["input"]
80
+ return [ds]
81
+
82
+ if not isinstance(self.output, Process):
83
+ raise ValueError("Output must be an instance of Process")
84
+
85
+ for product in self.output.products():
86
+ ds = deepcopy(self.as_dict())
87
+ ds["amount"] = ds["amount"] * product.get("allocation_factor", 1)
88
+ ds["output"] = product.key
89
+ edges.append(ds)
90
+
91
+ return edges
92
+
93
+ def save(self, signal: bool = True, data_already_set: bool = False, force_insert: bool = False):
94
+ """
95
+ Save the exchange to the database.
96
+
97
+ This method handles saving the exchange, ensuring that allocation rules are enforced
98
+ for production exchanges. It also updates the associated process and function as needed.
99
+
100
+ Args:
101
+ signal (bool, optional): Whether to send a signal after saving. Defaults to True.
102
+ data_already_set (bool, optional): Whether the data is already set. Defaults to False.
103
+ force_insert (bool, optional): Whether to force an insert operation. Defaults to False.
104
+
105
+ Raises:
106
+ NotImplementedError: If parameterization is attempted for production exchanges.
107
+ """
108
+ from .node_classes import Process, Product
109
+ log.debug(f"Saving {self['type']} Exchange: {self}")
110
+
111
+ created = self.id is None # the exchange is new if it has no id
112
+ old = ExchangeDataset.get_by_id(self.id) if not created else None
113
+
114
+ super().save(signal, data_already_set, force_insert)
115
+
116
+ function = self.input
117
+ process = self.output
118
+
119
+ if not isinstance(process, Process) or not isinstance(function, Product):
120
+ return
121
+
122
+ if self["type"] == "production":
123
+ if created:
124
+ # If the exchange is new and production, the process has a new function
125
+
126
+ # save the process to ensure the right type is set (multifunctional, process)
127
+ process.save()
128
+
129
+ # reallocate the process to update allocation factors based on the new set of functions
130
+ process.allocate()
131
+
132
+ # If the exchange is not new, we need to check if the amount has changed
133
+ elif old.data["amount"] != self["amount"]:
134
+ # If the amount has changed, we need to reallocate the process
135
+ process.allocate() # Includes function.save() for function type checking
136
+
137
+ def delete(self, signal: bool = True):
138
+ """
139
+ Delete the exchange from the database.
140
+
141
+ This method handles deleting the exchange and updates the associated process and function
142
+ as needed. For production exchanges, it also deletes the associated function.
143
+
144
+ Args:
145
+ signal (bool, optional): Whether to send a signal after deletion. Defaults to True.
146
+ """
147
+ from .node_classes import Product, Process, MFActivity
148
+ log.debug(f"Deleting {self['type']} Exchange: {self}")
149
+
150
+ super().delete(signal)
151
+
152
+ function = self.input
153
+ process = self.output
154
+
155
+ if not isinstance(process, Process) or not isinstance(function, Product):
156
+ return
157
+
158
+ if self["type"] == "production":
159
+ # delete associated function through Class directly to avoid cascading delete
160
+ MFActivity.delete(function)
161
+
162
+ # save the process to ensure the right type is set (multifunctional, process, or nonfunctional)
163
+ process.save()
164
+
165
+ # reallocate the process to update allocation factors based on the remaining functions
166
+ process.allocate()