brightpath 0.0.1__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.
- brightpath/__init__.py +9 -0
- brightpath/bwconverter.py +616 -0
- brightpath/data/export/correspondence_biosphere_flows.yaml +247 -0
- brightpath/data/export/ecoinvent_to_uvek_mapping.csv +659 -0
- brightpath/data/export/ei_transport.csv +277 -0
- brightpath/data/export/flows_biosphere_39.csv +4711 -0
- brightpath/data/export/simapro-biosphere.json +2842 -0
- brightpath/data/export/simapro-technosphere-3.5.csv +46564 -0
- brightpath/data/export/simapro_blacklist.yaml +15 -0
- brightpath/data/export/simapro_ei_exceptions.yaml +5 -0
- brightpath/data/export/simapro_fields.yaml +37 -0
- brightpath/data/export/simapro_headers.yaml +15 -0
- brightpath/data/export/simapro_subcompartments.yaml +16 -0
- brightpath/data/export/simapro_units.yaml +15 -0
- brightpath/data/export/uvek_blacklist.yaml +7 -0
- brightpath/data/export/uvek_conversion_factors.yaml +31 -0
- brightpath/data/export/waste_exchange_names.yaml +11 -0
- brightpath/simaproconverter.py +463 -0
- brightpath/utils.py +1181 -0
- brightpath-0.0.1.dist-info/METADATA +140 -0
- brightpath-0.0.1.dist-info/RECORD +24 -0
- brightpath-0.0.1.dist-info/WHEEL +5 -0
- brightpath-0.0.1.dist-info/licenses/LICENSE +29 -0
- brightpath-0.0.1.dist-info/top_level.txt +1 -0
brightpath/utils.py
ADDED
|
@@ -0,0 +1,1181 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, Tuple
|
|
7
|
+
|
|
8
|
+
import bw2io
|
|
9
|
+
import numpy as np
|
|
10
|
+
import yaml
|
|
11
|
+
from bw2io.importers.excel import ExcelImporter
|
|
12
|
+
from prettytable import PrettyTable
|
|
13
|
+
from voluptuous import Optional, Required, Schema, Url
|
|
14
|
+
|
|
15
|
+
from . import DATA_DIR
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(
|
|
18
|
+
level=logging.DEBUG,
|
|
19
|
+
filename="brightpath.log", # Log file to save the entries
|
|
20
|
+
filemode="a", # Append to the log file if it exists, 'w' to overwrite
|
|
21
|
+
format="%(asctime)s - %(levelname)s - %(module)s - %(message)s",
|
|
22
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_simapro_biosphere() -> Dict[str, str]:
|
|
27
|
+
"""Load the correspondence between ecoinvent and SimaPro biosphere flows.
|
|
28
|
+
|
|
29
|
+
:return: Mapping from an ecoinvent biosphere flow name to its SimaPro
|
|
30
|
+
equivalent name.
|
|
31
|
+
:rtype: dict[str, str]
|
|
32
|
+
:raises FileNotFoundError: If the mapping file is missing from
|
|
33
|
+
``brightpath/data/export``.
|
|
34
|
+
:raises json.JSONDecodeError: If the mapping file cannot be parsed.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
filename = "simapro-biosphere.json"
|
|
38
|
+
filepath = DATA_DIR / "export" / filename
|
|
39
|
+
if not filepath.is_file():
|
|
40
|
+
raise FileNotFoundError(
|
|
41
|
+
"The dictionary of biosphere flow match "
|
|
42
|
+
"between ecoinvent and Simapro could not be found."
|
|
43
|
+
)
|
|
44
|
+
with open(filepath, encoding="utf-8") as json_file:
|
|
45
|
+
data = json.load(json_file)
|
|
46
|
+
dict_bio = {}
|
|
47
|
+
for d in data:
|
|
48
|
+
dict_bio[d[2]] = d[1]
|
|
49
|
+
|
|
50
|
+
return dict_bio
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_simapro_subcompartments() -> Dict[str, str]:
|
|
54
|
+
"""Load the mapping of biosphere sub-compartments.
|
|
55
|
+
|
|
56
|
+
:return: Mapping from ecoinvent sub-compartment names to their SimaPro
|
|
57
|
+
equivalents.
|
|
58
|
+
:rtype: dict[str, str]
|
|
59
|
+
:raises FileNotFoundError: If the YAML file with the mapping is missing.
|
|
60
|
+
:raises yaml.YAMLError: If the YAML file cannot be parsed.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
filename = "simapro_subcompartments.yaml"
|
|
64
|
+
filepath = DATA_DIR / "export" / filename
|
|
65
|
+
if not filepath.is_file():
|
|
66
|
+
raise FileNotFoundError(
|
|
67
|
+
"The dictionary of subcompartments match "
|
|
68
|
+
"between ecoinvent and Simapro could not be found."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# read YAML file
|
|
72
|
+
with open(filepath, "r") as stream:
|
|
73
|
+
try:
|
|
74
|
+
data = yaml.safe_load(stream)
|
|
75
|
+
except yaml.YAMLError as exc:
|
|
76
|
+
print(exc)
|
|
77
|
+
|
|
78
|
+
return data
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_simapro_technosphere() -> Dict[Tuple[str, str], str]:
|
|
82
|
+
"""Load the correspondence between ecoinvent and SimaPro product flows.
|
|
83
|
+
|
|
84
|
+
:return: Mapping where the key is the pair ``(name, location)`` of an
|
|
85
|
+
ecoinvent technosphere exchange and the value is the SimaPro exchange
|
|
86
|
+
name.
|
|
87
|
+
:rtype: dict[tuple[str, str], str]
|
|
88
|
+
:raises FileNotFoundError: If the CSV mapping file is missing.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
filename = "simapro-technosphere-3.5.csv"
|
|
92
|
+
filepath = DATA_DIR / "export" / filename
|
|
93
|
+
with open(filepath, encoding="utf-8") as f:
|
|
94
|
+
csv_list = [[val.strip() for val in r.split(";")] for r in f.readlines()]
|
|
95
|
+
(_, _, *header), *data = csv_list
|
|
96
|
+
|
|
97
|
+
dict_tech = {}
|
|
98
|
+
for row in data:
|
|
99
|
+
name, location, simapro_name = row
|
|
100
|
+
simapro_name = simapro_name.split("|")[:2]
|
|
101
|
+
dict_tech[(name, location)] = "|".join(simapro_name)
|
|
102
|
+
|
|
103
|
+
return dict_tech
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_simapro_ecoinvent_blacklist():
|
|
107
|
+
"""Load the list of exchanges to exclude when exporting to SimaPro.
|
|
108
|
+
|
|
109
|
+
:return: Dictionary describing exchanges that must be skipped for the
|
|
110
|
+
ecoinvent export.
|
|
111
|
+
:rtype: dict
|
|
112
|
+
:raises FileNotFoundError: If the blacklist file is missing.
|
|
113
|
+
:raises yaml.YAMLError: If the blacklist file cannot be parsed.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
filename = "simapro_blacklist.yaml"
|
|
117
|
+
filepath = DATA_DIR / "export" / filename
|
|
118
|
+
# read YAML file
|
|
119
|
+
with open(filepath, "r") as stream:
|
|
120
|
+
try:
|
|
121
|
+
data = yaml.safe_load(stream)
|
|
122
|
+
except yaml.YAMLError as exc:
|
|
123
|
+
print(exc)
|
|
124
|
+
return data
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
simapro_ecoinvent_blacklist = get_simapro_ecoinvent_blacklist()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_simapro_uvek_blacklist():
|
|
131
|
+
"""Load the blacklist of SimaPro UVEK exchanges.
|
|
132
|
+
|
|
133
|
+
:return: Dictionary describing exchanges that must be skipped when
|
|
134
|
+
targeting the UVEK database.
|
|
135
|
+
:rtype: dict
|
|
136
|
+
:raises FileNotFoundError: If the blacklist YAML file cannot be found.
|
|
137
|
+
:raises yaml.YAMLError: If the blacklist file cannot be parsed.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
filename = "uvek_blacklist.yaml"
|
|
141
|
+
filepath = DATA_DIR / "export" / filename
|
|
142
|
+
# read YAML file
|
|
143
|
+
with open(filepath, "r") as stream:
|
|
144
|
+
try:
|
|
145
|
+
data = yaml.safe_load(stream)
|
|
146
|
+
except yaml.YAMLError as exc:
|
|
147
|
+
print(exc)
|
|
148
|
+
return data
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
simapro_uvek_blacklist = get_simapro_uvek_blacklist()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_ecoinvent_to_uvek_mapping():
|
|
155
|
+
"""Load the mapping between ecoinvent flows and UVEK identifiers.
|
|
156
|
+
|
|
157
|
+
:return: Dictionary keyed by a tuple consisting of the ecoinvent flow
|
|
158
|
+
name, location and additional qualifiers, pointing to the UVEK
|
|
159
|
+
identifier.
|
|
160
|
+
:rtype: dict[tuple[str, str, str, str], str]
|
|
161
|
+
:raises FileNotFoundError: If the CSV mapping file cannot be found.
|
|
162
|
+
"""
|
|
163
|
+
filename = "ecoinvent_to_uvek_mapping.csv"
|
|
164
|
+
filepath = DATA_DIR / "export" / filename
|
|
165
|
+
with open(filepath, "r") as file:
|
|
166
|
+
reader = csv.reader(file)
|
|
167
|
+
next(reader)
|
|
168
|
+
dictionary = {tuple(row[:4]): row[-1] for row in reader}
|
|
169
|
+
|
|
170
|
+
return dictionary
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def get_ecoinvent_transport_distances():
|
|
174
|
+
"""Load default transport distances for ecoinvent flows.
|
|
175
|
+
|
|
176
|
+
:return: Mapping from exchange name to a dictionary containing transport
|
|
177
|
+
distances per mode and region.
|
|
178
|
+
:rtype: dict[str, dict[str, str]]
|
|
179
|
+
:raises FileNotFoundError: If the CSV with transport distances is missing.
|
|
180
|
+
"""
|
|
181
|
+
filename = "ei_transport.csv"
|
|
182
|
+
filepath = DATA_DIR / "export" / filename
|
|
183
|
+
with open(filepath, "r") as file:
|
|
184
|
+
reader = csv.reader(file, delimiter=";")
|
|
185
|
+
next(reader)
|
|
186
|
+
dictionary = {
|
|
187
|
+
row[0]: {
|
|
188
|
+
"train - RER": row[3],
|
|
189
|
+
"lorry - RER": row[4],
|
|
190
|
+
"barge - RER": row[5],
|
|
191
|
+
"train - CH": row[6],
|
|
192
|
+
"lorry - CH": row[7],
|
|
193
|
+
"barge - CH": row[8],
|
|
194
|
+
}
|
|
195
|
+
for row in reader
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return dictionary
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
ecoinvent_uvek_mapping = get_ecoinvent_to_uvek_mapping()
|
|
202
|
+
ecoinvent_transport_distances = get_ecoinvent_transport_distances()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def get_simapro_fields_list() -> list[str]:
|
|
206
|
+
"""Return the ordered list of SimaPro section names.
|
|
207
|
+
|
|
208
|
+
:return: Sequence of field names that describes the structure of a
|
|
209
|
+
SimaPro CSV export.
|
|
210
|
+
:rtype: list[str]
|
|
211
|
+
:raises FileNotFoundError: If the YAML definition file is missing.
|
|
212
|
+
:raises yaml.YAMLError: If the YAML file cannot be parsed.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
filename = "simapro_fields.yaml"
|
|
216
|
+
filepath = DATA_DIR / "export" / filename
|
|
217
|
+
|
|
218
|
+
# read YAML file
|
|
219
|
+
with open(filepath, "r") as stream:
|
|
220
|
+
try:
|
|
221
|
+
data = yaml.safe_load(stream)
|
|
222
|
+
except yaml.YAMLError as exc:
|
|
223
|
+
print(exc)
|
|
224
|
+
return data
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_simapro_units():
|
|
228
|
+
"""Load the mapping of Brightway units to SimaPro units.
|
|
229
|
+
|
|
230
|
+
:return: Dictionary mapping source units to their SimaPro counterparts.
|
|
231
|
+
:rtype: dict[str, str]
|
|
232
|
+
:raises FileNotFoundError: If the YAML definition file is missing.
|
|
233
|
+
:raises yaml.YAMLError: If the YAML file cannot be parsed.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
filename = "simapro_units.yaml"
|
|
237
|
+
filepath = DATA_DIR / "export" / filename
|
|
238
|
+
|
|
239
|
+
# read YAML file
|
|
240
|
+
with open(filepath, "r") as stream:
|
|
241
|
+
try:
|
|
242
|
+
data = yaml.safe_load(stream)
|
|
243
|
+
except yaml.YAMLError as exc:
|
|
244
|
+
print(exc)
|
|
245
|
+
return data
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def get_simapro_headers():
|
|
249
|
+
"""Load the SimaPro header rows that precede each export.
|
|
250
|
+
|
|
251
|
+
:return: List of header strings used when generating SimaPro CSV files.
|
|
252
|
+
:rtype: list[str]
|
|
253
|
+
:raises FileNotFoundError: If the YAML definition file is missing.
|
|
254
|
+
:raises yaml.YAMLError: If the YAML file cannot be parsed.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
filename = "simapro_headers.yaml"
|
|
258
|
+
filepath = DATA_DIR / "export" / filename
|
|
259
|
+
|
|
260
|
+
# read YAML file
|
|
261
|
+
with open(filepath, "r") as stream:
|
|
262
|
+
try:
|
|
263
|
+
data = yaml.safe_load(stream)
|
|
264
|
+
except yaml.YAMLError as exc:
|
|
265
|
+
print(exc)
|
|
266
|
+
|
|
267
|
+
return data
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_simapro_ecoinvent_exceptions():
|
|
271
|
+
"""Load the list of special-case ecoinvent flows.
|
|
272
|
+
|
|
273
|
+
:return: Dictionary describing exchanges that require bespoke handling
|
|
274
|
+
during the conversion.
|
|
275
|
+
:rtype: dict
|
|
276
|
+
:raises FileNotFoundError: If the YAML exception file is missing.
|
|
277
|
+
:raises yaml.YAMLError: If the YAML file cannot be parsed.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
filename = "simapro_ei_exceptions.yaml"
|
|
281
|
+
filepath = DATA_DIR / "export" / filename
|
|
282
|
+
|
|
283
|
+
# read YAML file
|
|
284
|
+
with open(filepath, "r") as stream:
|
|
285
|
+
try:
|
|
286
|
+
data = yaml.safe_load(stream)
|
|
287
|
+
except yaml.YAMLError as exc:
|
|
288
|
+
print(exc)
|
|
289
|
+
|
|
290
|
+
return data
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
ecoinvent_exceptions = get_simapro_ecoinvent_exceptions()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def get_waste_exchange_names():
|
|
297
|
+
"""Return the keywords that identify waste-treatment exchanges.
|
|
298
|
+
|
|
299
|
+
:return: List of strings that indicate an exchange represents waste
|
|
300
|
+
treatment.
|
|
301
|
+
:rtype: list[str]
|
|
302
|
+
:raises FileNotFoundError: If the YAML file cannot be found.
|
|
303
|
+
:raises yaml.YAMLError: If the YAML file cannot be parsed.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
filename = "waste_exchange_names.yaml"
|
|
307
|
+
filepath = DATA_DIR / "export" / filename
|
|
308
|
+
|
|
309
|
+
# read YAML file
|
|
310
|
+
with open(filepath, "r") as stream:
|
|
311
|
+
try:
|
|
312
|
+
data = yaml.safe_load(stream)
|
|
313
|
+
except yaml.YAMLError as exc:
|
|
314
|
+
print(exc)
|
|
315
|
+
return data
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def check_inventories(data: list) -> None:
|
|
319
|
+
"""Validate that inventories contain the required information.
|
|
320
|
+
|
|
321
|
+
The function verifies that each exchange includes the mandatory keys
|
|
322
|
+
expected by the conversion logic. A :class:`ValueError` is raised when
|
|
323
|
+
missing data is detected and the offending exchanges are displayed.
|
|
324
|
+
|
|
325
|
+
:param data: Brightway activities that should be checked.
|
|
326
|
+
:type data: list[dict]
|
|
327
|
+
:raises ValueError: If an exchange misses mandatory attributes.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
MANDATORY_TECH_EXC_KEYS = ["name", "reference product", "location", "unit"]
|
|
331
|
+
|
|
332
|
+
MANDATORY_BIO_EXC_KEYS = ["name", "categories", "unit"]
|
|
333
|
+
|
|
334
|
+
faulty_exchanges = []
|
|
335
|
+
|
|
336
|
+
for activity in data:
|
|
337
|
+
for exchange in activity["exchanges"]:
|
|
338
|
+
if exchange["type"] in ["production", "technosphere"]:
|
|
339
|
+
if not all(key in exchange.keys() for key in MANDATORY_TECH_EXC_KEYS):
|
|
340
|
+
faulty_exchanges.append(
|
|
341
|
+
[
|
|
342
|
+
exchange.get("name"),
|
|
343
|
+
exchange.get("reference unit"),
|
|
344
|
+
exchange.get("location"),
|
|
345
|
+
"--",
|
|
346
|
+
exchange.get("unit"),
|
|
347
|
+
]
|
|
348
|
+
)
|
|
349
|
+
else:
|
|
350
|
+
if not all(key in exchange.keys() for key in MANDATORY_BIO_EXC_KEYS):
|
|
351
|
+
faulty_exchanges.append(
|
|
352
|
+
[
|
|
353
|
+
exchange.get("name"),
|
|
354
|
+
"--",
|
|
355
|
+
"--",
|
|
356
|
+
exchange.get("categories"),
|
|
357
|
+
exchange.get("unit"),
|
|
358
|
+
]
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# print prettytable with faulty exchanges
|
|
362
|
+
if len(faulty_exchanges) > 0:
|
|
363
|
+
table = PrettyTable()
|
|
364
|
+
table.field_names = [
|
|
365
|
+
"Name",
|
|
366
|
+
"Reference product",
|
|
367
|
+
"Location",
|
|
368
|
+
"Categories",
|
|
369
|
+
"Unit",
|
|
370
|
+
]
|
|
371
|
+
for exc in faulty_exchanges:
|
|
372
|
+
table.add_row(exc)
|
|
373
|
+
|
|
374
|
+
print(table)
|
|
375
|
+
raise ValueError(
|
|
376
|
+
"Some exchanges do not have mandatory exchange "
|
|
377
|
+
"fields (marked 'None' in table above)."
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def import_bw_inventories(filepath: str) -> list[dict]:
|
|
382
|
+
"""Load Brightway inventories from an Excel workbook.
|
|
383
|
+
|
|
384
|
+
The importer relies on :mod:`bw2io` to read Brightway exports and ensures
|
|
385
|
+
that the required migrations are available.
|
|
386
|
+
|
|
387
|
+
:param filepath: Path to the Excel inventory spreadsheet.
|
|
388
|
+
:type filepath: str
|
|
389
|
+
:return: List of activities in Brightway format.
|
|
390
|
+
:rtype: list[dict]
|
|
391
|
+
:raises FileNotFoundError: If ``filepath`` does not exist.
|
|
392
|
+
:raises ValueError: If the provided file does not have the ``.xlsx`` suffix.
|
|
393
|
+
"""
|
|
394
|
+
# using bw2io, we load the inventories contained
|
|
395
|
+
# in the spreadsheet file
|
|
396
|
+
# and return a list of dictionaries
|
|
397
|
+
|
|
398
|
+
# if filepath is a string, convert to Path object
|
|
399
|
+
if isinstance(filepath, str):
|
|
400
|
+
filepath = Path(filepath)
|
|
401
|
+
|
|
402
|
+
# check that filepath is a file
|
|
403
|
+
if not filepath.is_file():
|
|
404
|
+
raise FileNotFoundError("The file could not be found.")
|
|
405
|
+
# check that suffix is .xlsx
|
|
406
|
+
if filepath.suffix != ".xlsx":
|
|
407
|
+
raise ValueError("The file must be a .xlsx spreadsheet.")
|
|
408
|
+
|
|
409
|
+
# import the inventories
|
|
410
|
+
importer = ExcelImporter(filepath)
|
|
411
|
+
# check if all necessary migration files are present
|
|
412
|
+
if "biosphere-2-3-categories" not in bw2io.migrations:
|
|
413
|
+
bw2io.create_core_migrations()
|
|
414
|
+
importer.apply_strategies()
|
|
415
|
+
|
|
416
|
+
check_inventories(importer.data)
|
|
417
|
+
|
|
418
|
+
return importer.data
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def check_metadata(metadata: dict) -> dict:
|
|
422
|
+
"""Validate metadata against the expected schema.
|
|
423
|
+
|
|
424
|
+
:param metadata: Raw metadata read from the YAML file.
|
|
425
|
+
:type metadata: dict
|
|
426
|
+
:return: Sanitised metadata that matches the schema.
|
|
427
|
+
:rtype: dict
|
|
428
|
+
:raises voluptuous.error.MultipleInvalid: If validation fails.
|
|
429
|
+
"""
|
|
430
|
+
# metadata dictionary should conform to the following schema:
|
|
431
|
+
# Define the validation schema
|
|
432
|
+
system_description_schema = Schema(
|
|
433
|
+
{
|
|
434
|
+
Required("name"): str,
|
|
435
|
+
Optional("category"): str,
|
|
436
|
+
Optional("description"): str,
|
|
437
|
+
Optional("cut-off rules"): str,
|
|
438
|
+
Optional("energy model"): str,
|
|
439
|
+
Optional("transport model"): str,
|
|
440
|
+
Optional("allocation rules"): str,
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
literature_reference_schema = Schema(
|
|
445
|
+
{
|
|
446
|
+
Required("name"): str,
|
|
447
|
+
Optional("documentation link"): Url(),
|
|
448
|
+
Optional("comment"): str,
|
|
449
|
+
Optional("category"): str,
|
|
450
|
+
Optional("description"): str,
|
|
451
|
+
}
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
main_schema = Schema(
|
|
455
|
+
{
|
|
456
|
+
Required("system description"): system_description_schema,
|
|
457
|
+
Required("literature reference"): literature_reference_schema,
|
|
458
|
+
}
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Validate against schema
|
|
462
|
+
validated_data = main_schema(metadata)
|
|
463
|
+
|
|
464
|
+
return validated_data
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def load_inventory_metadata(filepath: str) -> dict:
|
|
468
|
+
"""Load and validate inventory metadata from disk.
|
|
469
|
+
|
|
470
|
+
:param filepath: Path to the YAML document containing the metadata.
|
|
471
|
+
:type filepath: str
|
|
472
|
+
:return: Validated metadata dictionary.
|
|
473
|
+
:rtype: dict
|
|
474
|
+
:raises FileNotFoundError: If the metadata file does not exist.
|
|
475
|
+
:raises ValueError: If the file does not have a ``.yaml`` extension.
|
|
476
|
+
:raises yaml.YAMLError: If the metadata file cannot be parsed.
|
|
477
|
+
:raises voluptuous.error.MultipleInvalid: If the metadata structure is
|
|
478
|
+
invalid.
|
|
479
|
+
"""
|
|
480
|
+
# if filepath is a string, convert to Path object
|
|
481
|
+
if isinstance(filepath, str):
|
|
482
|
+
filepath = Path(filepath)
|
|
483
|
+
|
|
484
|
+
# check that filepath is a file
|
|
485
|
+
if not filepath.is_file():
|
|
486
|
+
raise FileNotFoundError("The file could not be found.")
|
|
487
|
+
# check that suffix is .yaml
|
|
488
|
+
if filepath.suffix != ".yaml":
|
|
489
|
+
raise ValueError("The file must be a .yaml file.")
|
|
490
|
+
|
|
491
|
+
# read YAML file
|
|
492
|
+
with open(filepath, "r") as stream:
|
|
493
|
+
try:
|
|
494
|
+
data = yaml.safe_load(stream)
|
|
495
|
+
except yaml.YAMLError as exc:
|
|
496
|
+
print(exc)
|
|
497
|
+
|
|
498
|
+
# check that metadata is valid
|
|
499
|
+
data = check_metadata(data)
|
|
500
|
+
|
|
501
|
+
return data
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def is_activity_waste_treatment(activity: dict, database: str) -> bool:
|
|
505
|
+
"""Determine whether an activity represents waste treatment.
|
|
506
|
+
|
|
507
|
+
:param activity: Brightway activity dictionary to inspect.
|
|
508
|
+
:type activity: dict
|
|
509
|
+
:param database: Name of the target database used for heuristics.
|
|
510
|
+
:type database: str
|
|
511
|
+
:return: ``True`` if the activity is a waste treatment process.
|
|
512
|
+
:rtype: bool
|
|
513
|
+
"""
|
|
514
|
+
|
|
515
|
+
if "type" in activity:
|
|
516
|
+
if activity["type"] == "process":
|
|
517
|
+
return False
|
|
518
|
+
if activity["type"] == "waste treatment":
|
|
519
|
+
return True
|
|
520
|
+
|
|
521
|
+
if is_a_waste_treatment(activity["name"], database) is True:
|
|
522
|
+
return True
|
|
523
|
+
|
|
524
|
+
return False
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def is_a_waste_treatment(name: str, database: str) -> bool:
|
|
528
|
+
"""Check whether an exchange name matches waste-treatment keywords.
|
|
529
|
+
|
|
530
|
+
:param name: Exchange name to analyse.
|
|
531
|
+
:type name: str
|
|
532
|
+
:param database: Target database used to refine the decision.
|
|
533
|
+
:type database: str
|
|
534
|
+
:return: ``True`` if the exchange is considered waste treatment.
|
|
535
|
+
:rtype: bool
|
|
536
|
+
"""
|
|
537
|
+
WASTE_TERMS = get_waste_exchange_names()
|
|
538
|
+
NOT_WASTE_TERMS = [
|
|
539
|
+
# "plant",
|
|
540
|
+
"incineration plant"
|
|
541
|
+
]
|
|
542
|
+
|
|
543
|
+
if any(term.lower() in name.lower() for term in WASTE_TERMS) is True:
|
|
544
|
+
if any(term.lower() in name.lower() for term in NOT_WASTE_TERMS) is False:
|
|
545
|
+
if database == "ecoinvent":
|
|
546
|
+
if not any(
|
|
547
|
+
term.lower() in name.lower()
|
|
548
|
+
for term in ecoinvent_exceptions["waste"]
|
|
549
|
+
):
|
|
550
|
+
return True
|
|
551
|
+
else:
|
|
552
|
+
return False
|
|
553
|
+
return True
|
|
554
|
+
return False
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def find_production_exchange(activity: dict) -> dict:
|
|
558
|
+
"""Retrieve the production exchange from an activity.
|
|
559
|
+
|
|
560
|
+
:param activity: Activity whose production exchange should be returned.
|
|
561
|
+
:type activity: dict
|
|
562
|
+
:return: The production exchange of the activity.
|
|
563
|
+
:rtype: dict
|
|
564
|
+
:raises ValueError: If the activity does not contain a production exchange.
|
|
565
|
+
"""
|
|
566
|
+
for exc in activity["exchanges"]:
|
|
567
|
+
if exc["type"] == "production":
|
|
568
|
+
return exc
|
|
569
|
+
raise ValueError(
|
|
570
|
+
f"The activity {activity['name']} does " f"not have a production exchange."
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def get_technosphere_exchanges(activity: dict) -> list:
|
|
575
|
+
"""Return the technosphere exchanges from an activity.
|
|
576
|
+
|
|
577
|
+
:param activity: Activity for which technosphere exchanges should be
|
|
578
|
+
collected.
|
|
579
|
+
:type activity: dict
|
|
580
|
+
:return: Technosphere exchanges with non-zero amounts.
|
|
581
|
+
:rtype: list[dict]
|
|
582
|
+
"""
|
|
583
|
+
return [
|
|
584
|
+
exc
|
|
585
|
+
for exc in activity["exchanges"]
|
|
586
|
+
if exc["type"] == "technosphere" and exc["amount"] != 0
|
|
587
|
+
]
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def get_biosphere_exchanges(activity: dict, category: str = None) -> list:
|
|
591
|
+
"""Return biosphere exchanges optionally filtered by category.
|
|
592
|
+
|
|
593
|
+
:param activity: Activity for which biosphere exchanges should be
|
|
594
|
+
collected.
|
|
595
|
+
:type activity: dict
|
|
596
|
+
:param category: Biosphere compartment to filter for, e.g. ``"air"``.
|
|
597
|
+
:type category: str | None
|
|
598
|
+
:return: Biosphere exchanges that match the optional category.
|
|
599
|
+
:rtype: list[dict]
|
|
600
|
+
"""
|
|
601
|
+
return [
|
|
602
|
+
exc
|
|
603
|
+
for exc in activity["exchanges"]
|
|
604
|
+
if exc["type"] == "biosphere"
|
|
605
|
+
and exc.get("categories")[0] == category
|
|
606
|
+
and exc["amount"] != 0
|
|
607
|
+
]
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def format_exchange_name(
|
|
611
|
+
name: str, reference_product: str, location: str, unit: str, database: str
|
|
612
|
+
) -> str:
|
|
613
|
+
"""Format a Brightway exchange name for SimaPro compatibility.
|
|
614
|
+
|
|
615
|
+
:param name: Exchange name from the Brightway inventory.
|
|
616
|
+
:type name: str
|
|
617
|
+
:param reference_product: Reference product of the exchange.
|
|
618
|
+
:type reference_product: str
|
|
619
|
+
:param location: Location code associated with the exchange.
|
|
620
|
+
:type location: str
|
|
621
|
+
:param unit: Unit of the exchange.
|
|
622
|
+
:type unit: str
|
|
623
|
+
:param database: Target database used to select the formatting logic.
|
|
624
|
+
:type database: str
|
|
625
|
+
:return: Name formatted according to SimaPro conventions.
|
|
626
|
+
:rtype: str
|
|
627
|
+
"""
|
|
628
|
+
|
|
629
|
+
if database == "ecoinvent":
|
|
630
|
+
# first letter of `name` should be capitalized
|
|
631
|
+
reference_product = reference_product[0].upper() + reference_product[1:]
|
|
632
|
+
name = name[0].upper() + name[1:]
|
|
633
|
+
|
|
634
|
+
exchange_name = f"{reference_product} {{{location}}}| {name}"
|
|
635
|
+
|
|
636
|
+
for i in ["market for", "market group for"]:
|
|
637
|
+
if i in name.lower():
|
|
638
|
+
exchange_name = f"{reference_product} {{{location}}}"
|
|
639
|
+
reference_product = reference_product[0].lower() + reference_product[1:]
|
|
640
|
+
|
|
641
|
+
if (
|
|
642
|
+
reference_product.lower() in ecoinvent_exceptions["market"]
|
|
643
|
+
and location == "GLO"
|
|
644
|
+
):
|
|
645
|
+
exchange_name += f"| {i}"
|
|
646
|
+
else:
|
|
647
|
+
exchange_name += f"| {i} {reference_product}"
|
|
648
|
+
|
|
649
|
+
exchange_name += " | Cut-off, U"
|
|
650
|
+
|
|
651
|
+
else:
|
|
652
|
+
# check first if name appears in ecoinvent-uvek mapping list
|
|
653
|
+
if (name, location, unit, reference_product) in ecoinvent_uvek_mapping:
|
|
654
|
+
return ecoinvent_uvek_mapping[(name, location, unit, reference_product)]
|
|
655
|
+
# database to link to is uvek.
|
|
656
|
+
exchange_name = f"{name}/{location} U"
|
|
657
|
+
|
|
658
|
+
return exchange_name
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def get_simapro_uncertainty_type(uncertainty_type: int) -> str:
|
|
662
|
+
"""Map Brightway uncertainty codes to the SimaPro string representation.
|
|
663
|
+
|
|
664
|
+
:param uncertainty_type: Integer identifier of the uncertainty type.
|
|
665
|
+
:type uncertainty_type: int
|
|
666
|
+
:return: Human readable uncertainty label used by SimaPro.
|
|
667
|
+
:rtype: str
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
UNCERTAINITY_TYPES = {
|
|
671
|
+
0: "not defined",
|
|
672
|
+
1: "not defined",
|
|
673
|
+
2: "Lognormal",
|
|
674
|
+
3: "Normal",
|
|
675
|
+
4: "Uniform",
|
|
676
|
+
5: "Triangular",
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return UNCERTAINITY_TYPES.get(uncertainty_type, "not defined")
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def is_blacklisted(exchange: dict, database: str) -> bool:
|
|
683
|
+
"""Check whether an exchange should be excluded during conversion.
|
|
684
|
+
|
|
685
|
+
:param exchange: Exchange dictionary to inspect.
|
|
686
|
+
:type exchange: dict
|
|
687
|
+
:param database: Target database, ``"ecoinvent"`` or ``"uvek"``.
|
|
688
|
+
:type database: str
|
|
689
|
+
:return: ``True`` when the exchange must be ignored.
|
|
690
|
+
:rtype: bool
|
|
691
|
+
"""
|
|
692
|
+
|
|
693
|
+
if exchange["name"] in simapro_ecoinvent_blacklist:
|
|
694
|
+
return True
|
|
695
|
+
|
|
696
|
+
if database == "uvek":
|
|
697
|
+
if exchange["name"] in simapro_uvek_blacklist:
|
|
698
|
+
return True
|
|
699
|
+
|
|
700
|
+
return False
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def convert_sd_to_sd2(value: float, uncertainty_type: str) -> float:
|
|
704
|
+
"""Convert standard deviations according to SimaPro expectations.
|
|
705
|
+
|
|
706
|
+
:param value: Standard deviation or lognormal sigma from Brightway.
|
|
707
|
+
:type value: float
|
|
708
|
+
:param uncertainty_type: Uncertainty distribution label.
|
|
709
|
+
:type uncertainty_type: str
|
|
710
|
+
:return: Converted standard deviation compatible with SimaPro.
|
|
711
|
+
:rtype: float
|
|
712
|
+
"""
|
|
713
|
+
|
|
714
|
+
if uncertainty_type == "Lognormal":
|
|
715
|
+
return np.exp(value) ** 2
|
|
716
|
+
|
|
717
|
+
if uncertainty_type == "Normal":
|
|
718
|
+
# normal distribution
|
|
719
|
+
return value**2
|
|
720
|
+
|
|
721
|
+
if uncertainty_type in ["not defined", "Unspecified"]:
|
|
722
|
+
# normal distribution
|
|
723
|
+
return 0
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def get_uvek_conversion_factors() -> dict:
|
|
727
|
+
"""Load conversion factors specific to the UVEK database.
|
|
728
|
+
|
|
729
|
+
:return: Mapping of exchange names to conversion factors and units.
|
|
730
|
+
:rtype: dict
|
|
731
|
+
:raises FileNotFoundError: If the YAML file is missing.
|
|
732
|
+
:raises yaml.YAMLError: If the YAML file cannot be parsed.
|
|
733
|
+
"""
|
|
734
|
+
filename = "uvek_conversion_factors.yaml"
|
|
735
|
+
filepath = DATA_DIR / "export" / filename
|
|
736
|
+
|
|
737
|
+
# read YAML file
|
|
738
|
+
with open(filepath, "r") as stream:
|
|
739
|
+
try:
|
|
740
|
+
data = yaml.safe_load(stream)
|
|
741
|
+
except yaml.YAMLError as exc:
|
|
742
|
+
print(exc)
|
|
743
|
+
|
|
744
|
+
return data
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def round_floats_in_string(s):
|
|
748
|
+
"""Round floating point numbers found inside a string to two decimals.
|
|
749
|
+
|
|
750
|
+
:param s: Arbitrary text potentially containing floating point numbers.
|
|
751
|
+
:type s: str
|
|
752
|
+
:return: String where the embedded numbers have been rounded.
|
|
753
|
+
:rtype: str
|
|
754
|
+
"""
|
|
755
|
+
# Pattern to detect float numbers in a string
|
|
756
|
+
pattern = re.compile(r"[-+]?\d*\.\d+")
|
|
757
|
+
|
|
758
|
+
# Function to apply to each match
|
|
759
|
+
def round_match(match):
|
|
760
|
+
return str(round(float(match.group()), 2))
|
|
761
|
+
|
|
762
|
+
# Apply function to each match
|
|
763
|
+
return pattern.sub(round_match, s)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def get_subcategory(category: str) -> str:
|
|
767
|
+
"""Extract the SimaPro subcategory from a combined category string.
|
|
768
|
+
|
|
769
|
+
:param category: Category string containing ``/``-separated components.
|
|
770
|
+
:type category: str
|
|
771
|
+
:return: Subcategory formatted with backslashes as required by SimaPro.
|
|
772
|
+
:rtype: str
|
|
773
|
+
"""
|
|
774
|
+
|
|
775
|
+
if len(category.split("/")) > 1:
|
|
776
|
+
subcategory = category.split("/")[1:]
|
|
777
|
+
# replace "/" with backslash
|
|
778
|
+
subcategory = "\\".join(subcategory)
|
|
779
|
+
else:
|
|
780
|
+
subcategory = ""
|
|
781
|
+
|
|
782
|
+
return subcategory
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def flag_exchanges(activity: dict) -> dict:
|
|
786
|
+
"""Mark exchanges as unused before processing.
|
|
787
|
+
|
|
788
|
+
:param activity: Activity whose exchanges should be flagged.
|
|
789
|
+
:type activity: dict
|
|
790
|
+
:return: Activity with a ``used`` flag initialised on each exchange.
|
|
791
|
+
:rtype: dict
|
|
792
|
+
"""
|
|
793
|
+
|
|
794
|
+
for exc in activity["exchanges"]:
|
|
795
|
+
exc["used"] = False
|
|
796
|
+
|
|
797
|
+
return activity
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def print_unused_exchanges(inventories: list) -> None:
|
|
801
|
+
"""Display exchanges that were not converted.
|
|
802
|
+
|
|
803
|
+
:param inventories: Converted activities to inspect.
|
|
804
|
+
:type inventories: list[dict]
|
|
805
|
+
"""
|
|
806
|
+
|
|
807
|
+
unused_exchanges = []
|
|
808
|
+
exc_counter = 0
|
|
809
|
+
for activity in inventories:
|
|
810
|
+
for exc in activity["exchanges"]:
|
|
811
|
+
exc_counter += 1
|
|
812
|
+
if exc.get("used", False) is False and exc["amount"] != 0:
|
|
813
|
+
unused_exchanges.append(
|
|
814
|
+
(
|
|
815
|
+
exc["name"],
|
|
816
|
+
exc["unit"],
|
|
817
|
+
exc.get("location", "GLO"),
|
|
818
|
+
exc.get(
|
|
819
|
+
"categories",
|
|
820
|
+
),
|
|
821
|
+
)
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
if len(unused_exchanges) > 0:
|
|
825
|
+
print("The following exchanges have not been used:")
|
|
826
|
+
table = PrettyTable(
|
|
827
|
+
(
|
|
828
|
+
"Exchange",
|
|
829
|
+
"Amount",
|
|
830
|
+
"Location",
|
|
831
|
+
"Categories",
|
|
832
|
+
)
|
|
833
|
+
)
|
|
834
|
+
for row in list(set(unused_exchanges)):
|
|
835
|
+
table.add_row(row)
|
|
836
|
+
print(table)
|
|
837
|
+
else:
|
|
838
|
+
print(f"All {exc_counter} exchanges have been converted!")
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def check_exchanges_for_conversion(exchanges: list, database: str) -> list:
|
|
842
|
+
"""Apply database-specific conversion factors to exchanges.
|
|
843
|
+
|
|
844
|
+
:param exchanges: Exchanges that might require conversion.
|
|
845
|
+
:type exchanges: list[dict]
|
|
846
|
+
:param database: Target database identifier.
|
|
847
|
+
:type database: str
|
|
848
|
+
:return: Updated list of exchanges.
|
|
849
|
+
:rtype: list[dict]
|
|
850
|
+
"""
|
|
851
|
+
|
|
852
|
+
if database == "uvek":
|
|
853
|
+
conversion_factors = get_uvek_conversion_factors()
|
|
854
|
+
for exc in exchanges:
|
|
855
|
+
if exc["name"] in conversion_factors:
|
|
856
|
+
exc["amount"] *= conversion_factors[exc["name"]].get("factor", 1)
|
|
857
|
+
exc["unit"] = conversion_factors[exc["name"]].get("unit", exc["unit"])
|
|
858
|
+
|
|
859
|
+
return exchanges
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def fetch_transport_distance(name: str, location: str) -> tuple:
|
|
863
|
+
"""Return default transport distances for a product and location.
|
|
864
|
+
|
|
865
|
+
:param name: Name of the technosphere exchange.
|
|
866
|
+
:type name: str
|
|
867
|
+
:param location: Location code of the consuming activity.
|
|
868
|
+
:type location: str
|
|
869
|
+
:return: Distances for train, lorry and barge transport.
|
|
870
|
+
:rtype: tuple[float, float, float]
|
|
871
|
+
"""
|
|
872
|
+
|
|
873
|
+
if name in ecoinvent_transport_distances:
|
|
874
|
+
if location == "CH":
|
|
875
|
+
return (
|
|
876
|
+
float(ecoinvent_transport_distances[name]["train - CH"]),
|
|
877
|
+
float(ecoinvent_transport_distances[name]["lorry - CH"]),
|
|
878
|
+
float(ecoinvent_transport_distances[name]["barge - CH"]),
|
|
879
|
+
)
|
|
880
|
+
else:
|
|
881
|
+
return (
|
|
882
|
+
float(ecoinvent_transport_distances[name]["train - RER"]),
|
|
883
|
+
float(ecoinvent_transport_distances[name]["lorry - RER"]),
|
|
884
|
+
float(ecoinvent_transport_distances[name]["barge - RER"]),
|
|
885
|
+
)
|
|
886
|
+
else:
|
|
887
|
+
return 0.0, 0.0, 0.0
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def add_distri_transport(activity: dict) -> dict:
|
|
891
|
+
"""Add distribution transport exchanges required by the UVEK database.
|
|
892
|
+
|
|
893
|
+
:param activity: Activity that should receive additional transport
|
|
894
|
+
exchanges.
|
|
895
|
+
:type activity: dict
|
|
896
|
+
:return: Activity enriched with transport exchanges.
|
|
897
|
+
:rtype: dict
|
|
898
|
+
"""
|
|
899
|
+
|
|
900
|
+
train_ch, lorry_ch, barge_ch = (0.0, 0.0, 0.0)
|
|
901
|
+
train_rer, lorry_rer, barge_rer = (0.0, 0.0, 0.0)
|
|
902
|
+
distance_train_ch, distance_lorry_ch, distance_barge_ch = (0.0, 0.0, 0.0)
|
|
903
|
+
distance_train_rer, distance_lorry_rer, distance_barge_rer = (0.0, 0.0, 0.0)
|
|
904
|
+
|
|
905
|
+
for exc in get_technosphere_exchanges(activity):
|
|
906
|
+
if exc["unit"] == "kilogram":
|
|
907
|
+
train, lorry, barge = fetch_transport_distance(
|
|
908
|
+
exc["name"], activity["location"]
|
|
909
|
+
)
|
|
910
|
+
if activity["location"] == "CH":
|
|
911
|
+
train_ch += train * exc["amount"] / 1000.0
|
|
912
|
+
lorry_ch += lorry * exc["amount"] / 1000.0
|
|
913
|
+
barge_ch += barge * exc["amount"] / 1000.0
|
|
914
|
+
distance_train_ch += train
|
|
915
|
+
distance_lorry_ch += lorry
|
|
916
|
+
distance_barge_ch += barge
|
|
917
|
+
else:
|
|
918
|
+
train_rer += train * exc["amount"] / 1000.0
|
|
919
|
+
lorry_rer += lorry * exc["amount"] / 1000.0
|
|
920
|
+
barge_rer += barge * exc["amount"] / 1000.0
|
|
921
|
+
distance_train_rer += train
|
|
922
|
+
distance_lorry_rer += lorry
|
|
923
|
+
distance_barge_rer += barge
|
|
924
|
+
|
|
925
|
+
if train_ch > 0:
|
|
926
|
+
activity["exchanges"].append(
|
|
927
|
+
{
|
|
928
|
+
"name": "market for transport, freight train",
|
|
929
|
+
"reference product": "transport, freight train",
|
|
930
|
+
"amount": train_ch,
|
|
931
|
+
"unit": "ton kilometer",
|
|
932
|
+
"type": "technosphere",
|
|
933
|
+
"location": "CH",
|
|
934
|
+
"uncertainty type": 2,
|
|
935
|
+
"loc": np.log(train_ch),
|
|
936
|
+
"scale": 0.396,
|
|
937
|
+
"used": False,
|
|
938
|
+
"comment": "Generic transport distances calculated based on "
|
|
939
|
+
"Table 4.2 of the ecoinvent v.2 Methodology report. "
|
|
940
|
+
f"Distribution: {np.round((train_ch/distance_train_ch)*1000, 2)} kg "
|
|
941
|
+
f"over {np.round(distance_train_ch, 2)} km.",
|
|
942
|
+
}
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
if lorry_ch > 0:
|
|
946
|
+
activity["exchanges"].append(
|
|
947
|
+
{
|
|
948
|
+
"name": "market for transport, freight, lorry, unspecified",
|
|
949
|
+
"reference product": "transport, freight, lorry, unspecified",
|
|
950
|
+
"amount": lorry_ch,
|
|
951
|
+
"unit": "ton kilometer",
|
|
952
|
+
"type": "technosphere",
|
|
953
|
+
"location": "CH",
|
|
954
|
+
"uncertainty type": 2,
|
|
955
|
+
"loc": np.log(lorry_ch),
|
|
956
|
+
"scale": 0.396,
|
|
957
|
+
"used": False,
|
|
958
|
+
"comment": "Generic transport distances calculated based on "
|
|
959
|
+
"Table 4.2 of the ecoinvent v.2 Methodology report. "
|
|
960
|
+
f"Distribution: {np.round((lorry_ch/distance_lorry_ch)*1000, 2)} kg "
|
|
961
|
+
f"over {np.round(distance_lorry_ch, 2)} km.",
|
|
962
|
+
}
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
if barge_ch > 0:
|
|
966
|
+
activity["exchanges"].append(
|
|
967
|
+
{
|
|
968
|
+
"name": "market for transport, freight, inland waterways, barge",
|
|
969
|
+
"reference product": "transport, freight, inland waterways, barge",
|
|
970
|
+
"amount": barge_ch,
|
|
971
|
+
"unit": "ton kilometer",
|
|
972
|
+
"type": "technosphere",
|
|
973
|
+
"location": "RER",
|
|
974
|
+
"uncertainty type": 2,
|
|
975
|
+
"loc": np.log(barge_ch),
|
|
976
|
+
"scale": 0.396,
|
|
977
|
+
"used": False,
|
|
978
|
+
"comment": "Generic transport distances calculated based on "
|
|
979
|
+
"Table 4.2 of the ecoinvent v.2 Methodology report. "
|
|
980
|
+
f"Distribution: {np.round((barge_ch/distance_barge_ch)*1000, 2)} kg "
|
|
981
|
+
f"over {np.round(distance_barge_ch, 2)} km.",
|
|
982
|
+
}
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
if train_rer > 0:
|
|
986
|
+
activity["exchanges"].append(
|
|
987
|
+
{
|
|
988
|
+
"name": "market for transport, freight train",
|
|
989
|
+
"reference product": "transport, freight train",
|
|
990
|
+
"amount": train_rer,
|
|
991
|
+
"unit": "ton kilometer",
|
|
992
|
+
"type": "technosphere",
|
|
993
|
+
"location": "Europe without Switzerland",
|
|
994
|
+
"uncertainty type": 2,
|
|
995
|
+
"loc": np.log(train_rer),
|
|
996
|
+
"scale": 0.396,
|
|
997
|
+
"used": False,
|
|
998
|
+
"comment": "Generic transport distances calculated based on "
|
|
999
|
+
"Table 4.2 of the ecoinvent v.2 Methodology report. "
|
|
1000
|
+
f"Distribution: {np.round((train_rer/distance_train_rer)*1000, 2)} kg "
|
|
1001
|
+
f"over {np.round(distance_train_rer, 2)} km.",
|
|
1002
|
+
}
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
if lorry_rer > 0:
|
|
1006
|
+
activity["exchanges"].append(
|
|
1007
|
+
{
|
|
1008
|
+
"name": "market for transport, freight, lorry, unspecified",
|
|
1009
|
+
"reference product": "transport, freight, lorry, unspecified",
|
|
1010
|
+
"amount": lorry_rer,
|
|
1011
|
+
"unit": "ton kilometer",
|
|
1012
|
+
"type": "technosphere",
|
|
1013
|
+
"location": "RER",
|
|
1014
|
+
"uncertainty type": 2,
|
|
1015
|
+
"loc": np.log(lorry_rer),
|
|
1016
|
+
"scale": 0.396,
|
|
1017
|
+
"used": False,
|
|
1018
|
+
"comment": "Generic transport distances calculated based on "
|
|
1019
|
+
"Table 4.2 of the ecoinvent v.2 Methodology report. "
|
|
1020
|
+
f"Distribution: {np.round((lorry_rer/distance_lorry_rer)*1000, 2)} kg "
|
|
1021
|
+
f"over {np.round(distance_lorry_rer, 2)} km.",
|
|
1022
|
+
}
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
if barge_rer > 0:
|
|
1026
|
+
activity["exchanges"].append(
|
|
1027
|
+
{
|
|
1028
|
+
"name": "market for transport, freight, inland waterways, barge",
|
|
1029
|
+
"reference product": "transport, freight, inland waterways, barge",
|
|
1030
|
+
"amount": barge_rer,
|
|
1031
|
+
"unit": "ton kilometer",
|
|
1032
|
+
"type": "technosphere",
|
|
1033
|
+
"location": "RER",
|
|
1034
|
+
"uncertainty type": 2,
|
|
1035
|
+
"loc": np.log(barge_rer),
|
|
1036
|
+
"scale": 0.396,
|
|
1037
|
+
"used": False,
|
|
1038
|
+
"comment": "Generic transport distances calculated based on "
|
|
1039
|
+
"Table 4.2 of the ecoinvent v.2 Methodology report. "
|
|
1040
|
+
f"Distribution: {np.round((barge_rer/distance_barge_rer)*1000, 2)} kg "
|
|
1041
|
+
f"over {np.round(distance_barge_rer, 2)} km.",
|
|
1042
|
+
}
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
return activity
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def remove_duplicates(data):
|
|
1049
|
+
"""Remove datasets that share the same name from a list of activities.
|
|
1050
|
+
|
|
1051
|
+
:param data: Activities to deduplicate.
|
|
1052
|
+
:type data: list[dict]
|
|
1053
|
+
:return: New list containing only the first occurrence of each dataset.
|
|
1054
|
+
:rtype: list[dict]
|
|
1055
|
+
"""
|
|
1056
|
+
a = []
|
|
1057
|
+
acts = []
|
|
1058
|
+
for x in data:
|
|
1059
|
+
if x["name"] not in a:
|
|
1060
|
+
a.append(x["name"])
|
|
1061
|
+
acts.append(x)
|
|
1062
|
+
else:
|
|
1063
|
+
logging.warning(f"Duplicate found: {x['name']}")
|
|
1064
|
+
return acts
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def check_simapro_inventory(file):
|
|
1068
|
+
"""Check a SimaPro CSV file for forbidden units.
|
|
1069
|
+
|
|
1070
|
+
:param file: Path to the CSV inventory file.
|
|
1071
|
+
:type file: str
|
|
1072
|
+
:return: Path to the cleaned CSV file with forbidden units replaced.
|
|
1073
|
+
:rtype: str
|
|
1074
|
+
"""
|
|
1075
|
+
# read CSV file
|
|
1076
|
+
new_file_data = []
|
|
1077
|
+
with open(file, "r", encoding="latin-1") as f:
|
|
1078
|
+
data = csv.reader(f, delimiter=";")
|
|
1079
|
+
for r, row in enumerate(data):
|
|
1080
|
+
row = search_for_forbidden_units(row)
|
|
1081
|
+
for v, val in enumerate(row):
|
|
1082
|
+
search_for_forbidden_units(val)
|
|
1083
|
+
new_file_data.append(row)
|
|
1084
|
+
|
|
1085
|
+
# save new file
|
|
1086
|
+
with open(
|
|
1087
|
+
file.lower().replace(".csv", "_edited.csv"),
|
|
1088
|
+
mode="w",
|
|
1089
|
+
encoding="latin-1",
|
|
1090
|
+
newline="",
|
|
1091
|
+
) as e:
|
|
1092
|
+
writer = csv.writer(e, delimiter=";")
|
|
1093
|
+
for row in new_file_data:
|
|
1094
|
+
writer.writerow(row)
|
|
1095
|
+
|
|
1096
|
+
logging.info(
|
|
1097
|
+
f"New inventory file saved as: {file.lower().replace('.csv', '_edited.csv')}."
|
|
1098
|
+
)
|
|
1099
|
+
return file.lower().replace(".csv", "_edited.csv")
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def search_for_forbidden_units(row: list) -> list:
|
|
1103
|
+
"""Replace forbidden units found in a CSV row.
|
|
1104
|
+
|
|
1105
|
+
:param row: Row values to inspect.
|
|
1106
|
+
:type row: list[str]
|
|
1107
|
+
:return: Row with forbidden units replaced by allowed ones.
|
|
1108
|
+
:rtype: list[str]
|
|
1109
|
+
"""
|
|
1110
|
+
FORBIDDEN_UNITS = {
|
|
1111
|
+
"min": "minute",
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
for v, val in enumerate(row):
|
|
1115
|
+
if val in FORBIDDEN_UNITS:
|
|
1116
|
+
logging.warning(f"Unit {val} replaced by {FORBIDDEN_UNITS[val]}.")
|
|
1117
|
+
row[v] = FORBIDDEN_UNITS[val]
|
|
1118
|
+
|
|
1119
|
+
return row
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def load_biosphere_correspondence():
|
|
1123
|
+
"""Load the correspondence between SimaPro and ecoinvent biosphere flows.
|
|
1124
|
+
|
|
1125
|
+
:return: Mapping of biosphere flows grouped by compartment.
|
|
1126
|
+
:rtype: dict
|
|
1127
|
+
:raises FileNotFoundError: If the correspondence file cannot be found.
|
|
1128
|
+
:raises yaml.YAMLError: If the YAML file cannot be parsed.
|
|
1129
|
+
"""
|
|
1130
|
+
filename = "correspondence_biosphere_flows.yaml"
|
|
1131
|
+
filepath = DATA_DIR / "export" / filename
|
|
1132
|
+
if not filepath.is_file():
|
|
1133
|
+
raise FileNotFoundError(
|
|
1134
|
+
"The dictionary of subcompartments match "
|
|
1135
|
+
"between ecoinvent and Simapro could not be found."
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
# read YAML file
|
|
1139
|
+
with open(filepath, "r") as stream:
|
|
1140
|
+
try:
|
|
1141
|
+
data = yaml.safe_load(stream)
|
|
1142
|
+
except yaml.YAMLError as exc:
|
|
1143
|
+
print(exc)
|
|
1144
|
+
|
|
1145
|
+
return data
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def load_ei_biosphere_flows():
|
|
1149
|
+
"""Load the list of biosphere flows available in ecoinvent.
|
|
1150
|
+
|
|
1151
|
+
:return: Unique set of tuples ``(name, category, subcategory)``.
|
|
1152
|
+
:rtype: list[tuple[str, str, str]]
|
|
1153
|
+
:raises FileNotFoundError: If the biosphere flow file cannot be found.
|
|
1154
|
+
"""
|
|
1155
|
+
filename = "flows_biosphere_39.csv"
|
|
1156
|
+
filepath = DATA_DIR / "export" / filename
|
|
1157
|
+
if not filepath.is_file():
|
|
1158
|
+
raise FileNotFoundError(
|
|
1159
|
+
"The dictionary of subcompartments match "
|
|
1160
|
+
"between ecoinvent and Simapro could not be found."
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
with open(filepath, encoding="utf-8") as f:
|
|
1164
|
+
data = [[val.strip() for val in r.split(";")] for r in f.readlines()]
|
|
1165
|
+
|
|
1166
|
+
return list(set([(r[0], r[1], r[2]) for r in data]))
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def lower_cap_first_letter(s):
|
|
1170
|
+
"""Lowercase the first character unless the input starts with an acronym.
|
|
1171
|
+
|
|
1172
|
+
:param s: String to normalise.
|
|
1173
|
+
:type s: str
|
|
1174
|
+
:return: Adjusted string that preserves acronyms.
|
|
1175
|
+
:rtype: str
|
|
1176
|
+
"""
|
|
1177
|
+
# Check if the string starts with an acronym (all uppercase letters
|
|
1178
|
+
# followed by a space, end of string, dash, or comma)
|
|
1179
|
+
if re.match(r"^[A-Z]+(\s|$|-|,)", s):
|
|
1180
|
+
return s # Keep acronyms unchanged
|
|
1181
|
+
return s[0].lower() + s[1:] if s else s # Lowercase first letter otherwise
|