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/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