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,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()
|