shrecc 0.1.0.dev2__tar.gz → 0.1.0.dev3__tar.gz

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.
Files changed (50) hide show
  1. {shrecc-0.1.0.dev2/shrecc.egg-info → shrecc-0.1.0.dev3}/PKG-INFO +3 -2
  2. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/pyproject.toml +2 -1
  3. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/__init__.py +1 -1
  4. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/activity_mapping.py +4 -0
  5. shrecc-0.1.0.dev3/shrecc/analysis.py +482 -0
  6. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/database.py +9 -0
  7. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/mapping.py +230 -0
  8. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/pipeline.py +319 -42
  9. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3/shrecc.egg-info}/PKG-INFO +3 -2
  10. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc.egg-info/SOURCES.txt +2 -0
  11. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc.egg-info/requires.txt +2 -1
  12. shrecc-0.1.0.dev3/tests/test_analysis.py +171 -0
  13. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/tests/test_database.py +8 -2
  14. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/tests/test_mapping.py +162 -0
  15. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/tests/test_pipeline.py +306 -2
  16. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/LICENSE +0 -0
  17. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/MANIFEST.in +0 -0
  18. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/README.md +0 -0
  19. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/setup.cfg +0 -0
  20. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/_legacy_database.py +0 -0
  21. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/_legacy_treatment.py +0 -0
  22. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/_legacy_tyndp.py +0 -0
  23. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/data/__init__.py +0 -0
  24. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/data/el_map_all_norm.csv +0 -0
  25. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/data/el_map_all_norm_w_ned.csv +0 -0
  26. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/data/generation_units_by_country.csv +0 -0
  27. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/data/techs_agg.json +0 -0
  28. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/data/tyndp/__init__.py +0 -0
  29. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/data/tyndp/remind-eu-topology.json +0 -0
  30. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/data/tyndp/tyndp_activities.csv +0 -0
  31. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/data/tyndp/tyndp_connections.csv +0 -0
  32. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/data/tyndp/tyndp_countries.csv +0 -0
  33. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/download.py +0 -0
  34. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/energy_charts.py +0 -0
  35. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/premise_mapping.py +0 -0
  36. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/result_store.py +0 -0
  37. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/solver.py +0 -0
  38. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/treatment.py +0 -0
  39. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/treatment_fiona.py +0 -0
  40. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc/tyndp.py +0 -0
  41. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc.egg-info/dependency_links.txt +0 -0
  42. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/shrecc.egg-info/top_level.txt +0 -0
  43. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/tests/test_architecture.py +0 -0
  44. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/tests/test_energy_charts.py +0 -0
  45. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/tests/test_energy_charts_api.py +0 -0
  46. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/tests/test_premise_mapping.py +0 -0
  47. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/tests/test_result_store.py +0 -0
  48. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/tests/test_solver.py +0 -0
  49. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/tests/test_treatment.py +0 -0
  50. {shrecc-0.1.0.dev2 → shrecc-0.1.0.dev3}/tests/test_tyndp_scenario.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrecc
3
- Version: 0.1.0.dev2
3
+ Version: 0.1.0.dev3
4
4
  Summary: SHRECC: Smooth Hourly Resolution Electricity Consumption Calculation
5
5
  Author-email: Sabina Bednářová <sabina.bednarova@list.lu>
6
6
  Maintainer-email: Sabina Bednářová <sabina.bednarova@list.lu>
@@ -21,13 +21,14 @@ Classifier: Topic :: Scientific/Engineering
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
+ Requires-Dist: bw2calc
24
25
  Requires-Dist: bw2data>=4.4.2
25
26
  Requires-Dist: pypardiso
26
27
  Requires-Dist: pandas<3,>=2
27
28
  Requires-Dist: numpy<3
28
29
  Requires-Dist: numpy>2; python_version >= "3.13"
29
30
  Requires-Dist: requests>=2.32
30
- Requires-Dist: scipy>=1.14
31
+ Requires-Dist: scipy
31
32
  Requires-Dist: appdirs
32
33
  Requires-Dist: packaging
33
34
  Requires-Dist: tqdm
@@ -31,13 +31,14 @@ requires-python = ">=3.10"
31
31
  dependencies = [
32
32
  # dependencies as strings with quotes, e.g. "foo"
33
33
  # You can add version requirements like "foo>2.0"
34
+ "bw2calc",
34
35
  "bw2data>=4.4.2",
35
36
  "pypardiso",
36
37
  "pandas>=2,<3",
37
38
  "numpy<3",
38
39
  'numpy>2;python_version>="3.13"',
39
40
  "requests>=2.32",
40
- "scipy>=1.14",
41
+ "scipy",
41
42
  "appdirs",
42
43
  "packaging",
43
44
  "tqdm",
@@ -17,4 +17,4 @@ from .database import create_database, filt_cutoff
17
17
  from .energy_charts import data_processing, get_data
18
18
  from .pipeline import NewDatabase
19
19
 
20
- __version__ = "0.1.0.dev2"
20
+ __version__ = "0.1.0.dev3"
@@ -7,18 +7,22 @@ from shrecc.mapping import (
7
7
  DEFAULT_COUNTRY_ALIASES,
8
8
  DEFAULT_FALLBACK_ACTIVITY,
9
9
  activity_mix_to_database_table,
10
+ aggregate_consumption_mix,
10
11
  filter_consumption_mix_time,
11
12
  load_ecoinvent_mapping,
12
13
  map_consumption_mix_to_ecoinvent_activities,
13
14
  mapping_gap_to_report,
15
+ validate_consumption_profile,
14
16
  )
15
17
 
16
18
  __all__ = (
17
19
  "DEFAULT_COUNTRY_ALIASES",
18
20
  "DEFAULT_FALLBACK_ACTIVITY",
19
21
  "activity_mix_to_database_table",
22
+ "aggregate_consumption_mix",
20
23
  "filter_consumption_mix_time",
21
24
  "load_ecoinvent_mapping",
22
25
  "map_consumption_mix_to_ecoinvent_activities",
23
26
  "mapping_gap_to_report",
27
+ "validate_consumption_profile",
24
28
  )
@@ -0,0 +1,482 @@
1
+ """Brightway analysis helpers for SHRECC electricity inventories."""
2
+
3
+ from collections import defaultdict
4
+ from collections.abc import Mapping
5
+ from gc import collect
6
+
7
+ import bw2data as bd
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ DEFAULT_COUNTRY_ALIASES = {"NIE": "GB", "UK": "GB"}
12
+ DEFAULT_BACKGROUND_LABEL = "Background"
13
+
14
+ __all__ = (
15
+ "compare_electricity_generation_mixes",
16
+ "compare_electricity_lcia",
17
+ "resolve_electricity_generation_shares",
18
+ )
19
+
20
+
21
+ class _ActivityResolver:
22
+ """Cache Brightway lookups for one analysis call."""
23
+
24
+ def __init__(self):
25
+ self._database_indexes = {}
26
+ self._nodes = {}
27
+ self._metadata = {}
28
+ self._electricity_inputs = {}
29
+
30
+ def find_unique(self, database_name, activity_name, location):
31
+ if database_name not in self._database_indexes:
32
+ index = {}
33
+ duplicates = set()
34
+ for activity in bd.Database(database_name):
35
+ key = (activity.get("name"), activity.get("location"))
36
+ if key in index:
37
+ duplicates.add(key)
38
+ else:
39
+ index[key] = activity
40
+ self._database_indexes[database_name] = (index, duplicates)
41
+
42
+ index, duplicates = self._database_indexes[database_name]
43
+ key = (activity_name, location)
44
+ return None if key in duplicates else index.get(key)
45
+
46
+ def node(self, activity_id):
47
+ if activity_id not in self._nodes:
48
+ self._nodes[activity_id] = bd.get_node(id=activity_id)
49
+ return self._nodes[activity_id]
50
+
51
+ def metadata(self, activity_id):
52
+ if activity_id not in self._metadata:
53
+ activity = self.node(activity_id)
54
+ self._metadata[activity_id] = (
55
+ activity.get("name"),
56
+ activity.get("location"),
57
+ )
58
+ return self._metadata[activity_id]
59
+
60
+ def electricity_inputs(self, activity_id):
61
+ if activity_id not in self._electricity_inputs:
62
+ activity = self.node(activity_id)
63
+ self._electricity_inputs[activity_id] = tuple(
64
+ (exchange.input.id, float(exchange["amount"]))
65
+ for exchange in activity.technosphere()
66
+ if float(exchange["amount"]) > 0
67
+ and _is_electricity_activity(exchange.input)
68
+ )
69
+ return self._electricity_inputs[activity_id]
70
+
71
+
72
+ def resolve_electricity_generation_shares(
73
+ root_activity,
74
+ *,
75
+ tolerance=1e-10,
76
+ max_iterations=500,
77
+ resolver=None,
78
+ ):
79
+ """Resolve delivered electricity through wrappers to generation shares.
80
+
81
+ Positive electricity exchanges are propagated through markets, voltage
82
+ transformations, import activities, and IAM-region wrappers. Traversal
83
+ stops at generating activities or at terminal activities without further
84
+ electricity inputs. Activities with the same name are combined across
85
+ locations so foreground and background technology families can be
86
+ compared directly.
87
+
88
+ Args:
89
+ root_activity: Brightway activity supplying delivered electricity.
90
+ tolerance: Remaining wrapper volume below which traversal is complete.
91
+ max_iterations: Maximum wrapper-propagation iterations.
92
+ resolver: Optional run-scoped resolver, primarily for composing
93
+ repeated analyses.
94
+
95
+ Returns:
96
+ A pair containing a descending Series of normalized generation shares
97
+ and a diagnostics dictionary.
98
+
99
+ Raises:
100
+ RuntimeError: If wrapper traversal does not converge.
101
+ ValueError: If no terminal electricity supply is found.
102
+ """
103
+ if tolerance <= 0:
104
+ raise ValueError("tolerance must be greater than zero")
105
+ if not isinstance(max_iterations, int) or max_iterations <= 0:
106
+ raise ValueError("max_iterations must be a positive integer")
107
+
108
+ resolver = resolver or _ActivityResolver()
109
+ terminal_volumes = defaultdict(float)
110
+ nonstandard_terminals = defaultdict(float)
111
+ frontier = {root_activity.id: 1.0}
112
+ remaining_volume = 0.0
113
+
114
+ for iteration in range(1, max_iterations + 1):
115
+ next_frontier = defaultdict(float)
116
+ for activity_id, required_volume in frontier.items():
117
+ activity = resolver.node(activity_id)
118
+ inputs = resolver.electricity_inputs(activity_id)
119
+ generation = _is_generation_activity(activity)
120
+ if generation or not inputs:
121
+ name, location = resolver.metadata(activity_id)
122
+ terminal_volumes[name] += required_volume
123
+ if not generation:
124
+ nonstandard_terminals[(name, location)] += required_volume
125
+ continue
126
+
127
+ for input_id, amount in inputs:
128
+ next_frontier[input_id] += required_volume * amount
129
+
130
+ remaining_volume = sum(abs(value) for value in next_frontier.values())
131
+ if remaining_volume <= tolerance:
132
+ break
133
+ frontier = dict(next_frontier)
134
+ else:
135
+ largest = sorted(
136
+ (
137
+ (*resolver.metadata(activity_id), volume)
138
+ for activity_id, volume in frontier.items()
139
+ ),
140
+ key=lambda row: abs(row[-1]),
141
+ reverse=True,
142
+ )[:5]
143
+ raise RuntimeError(
144
+ "Electricity wrapper traversal did not converge. Largest "
145
+ f"remaining activities: {largest}"
146
+ )
147
+
148
+ terminal_volumes = pd.Series(terminal_volumes, dtype=float)
149
+ terminal_total = terminal_volumes.sum()
150
+ if terminal_total <= 0:
151
+ raise ValueError(
152
+ f"No terminal electricity generation found below {root_activity}"
153
+ )
154
+
155
+ diagnostics = {
156
+ "iterations": iteration,
157
+ "terminal generation volume": terminal_total,
158
+ "remaining wrapper volume": remaining_volume,
159
+ "nonstandard terminal volume": sum(nonstandard_terminals.values()),
160
+ }
161
+ shares = (terminal_volumes / terminal_total).sort_values(ascending=False)
162
+ return shares, diagnostics
163
+
164
+
165
+ def compare_electricity_generation_mixes(
166
+ foreground_databases,
167
+ background_databases,
168
+ *,
169
+ countries,
170
+ years,
171
+ background_label=DEFAULT_BACKGROUND_LABEL,
172
+ country_aliases=None,
173
+ ):
174
+ """Compare foreground and background delivered-electricity compositions.
175
+
176
+ Args:
177
+ foreground_databases: Mapping from display label to ``{year:
178
+ database_name}`` mappings. Foreground databases are expected to
179
+ contain activities named ``"Electricity mix in {country}, {year}"``.
180
+ background_databases: Mapping from year to ecoinvent or premise
181
+ background database name.
182
+ countries: Consumer-country codes to compare.
183
+ years: Model years to compare.
184
+ background_label: Display label assigned to background rows.
185
+ country_aliases: Optional country-to-background-location aliases.
186
+
187
+ Returns:
188
+ Three DataFrames: long-form generation shares, missing activity roots,
189
+ and traversal diagnostics.
190
+ """
191
+ years, countries, foreground_databases, background_databases = (
192
+ _validate_comparison_inputs(
193
+ foreground_databases,
194
+ background_databases,
195
+ countries,
196
+ years,
197
+ background_label,
198
+ )
199
+ )
200
+ resolver = _ActivityResolver()
201
+ aliases = {**DEFAULT_COUNTRY_ALIASES, **(country_aliases or {})}
202
+ mix_rows = []
203
+ gaps = []
204
+ diagnostics_rows = []
205
+
206
+ for year in years:
207
+ for country in countries:
208
+ roots = _comparison_roots(
209
+ foreground_databases,
210
+ background_databases,
211
+ year,
212
+ country,
213
+ background_label,
214
+ aliases,
215
+ resolver,
216
+ )
217
+ for model, root_activity in roots.items():
218
+ if root_activity is None:
219
+ gaps.append(
220
+ {
221
+ "year": year,
222
+ "country": country,
223
+ "model": model,
224
+ "missing": "delivered low-voltage electricity activity",
225
+ }
226
+ )
227
+ continue
228
+
229
+ shares, diagnostics = resolve_electricity_generation_shares(
230
+ root_activity,
231
+ resolver=resolver,
232
+ )
233
+ diagnostics_rows.append(
234
+ {
235
+ "year": year,
236
+ "country": country,
237
+ "model": model,
238
+ "root activity": root_activity.get("name"),
239
+ **diagnostics,
240
+ }
241
+ )
242
+ mix_rows.extend(
243
+ {
244
+ "year": year,
245
+ "country": country,
246
+ "model": model,
247
+ "technology": technology,
248
+ "share": share,
249
+ }
250
+ for technology, share in shares.items()
251
+ )
252
+
253
+ return (
254
+ pd.DataFrame(
255
+ mix_rows,
256
+ columns=["year", "country", "model", "technology", "share"],
257
+ ),
258
+ pd.DataFrame(
259
+ gaps,
260
+ columns=["year", "country", "model", "missing"],
261
+ ),
262
+ pd.DataFrame(diagnostics_rows),
263
+ )
264
+
265
+
266
+ def compare_electricity_lcia(
267
+ foreground_databases,
268
+ background_databases,
269
+ *,
270
+ countries,
271
+ years,
272
+ method,
273
+ background_label=DEFAULT_BACKGROUND_LABEL,
274
+ country_aliases=None,
275
+ verbose=False,
276
+ ):
277
+ """Calculate foreground and background LCIA scores per kWh.
278
+
279
+ Calculations are batched by year to bound Brightway matrix memory.
280
+
281
+ Args:
282
+ foreground_databases: Mapping from display label to ``{year:
283
+ database_name}`` mappings.
284
+ background_databases: Mapping from year to background database name.
285
+ countries: Consumer-country codes to compare.
286
+ years: Model years to compare.
287
+ method: Brightway impact-category tuple.
288
+ background_label: Display label assigned to background rows.
289
+ country_aliases: Optional country-to-background-location aliases.
290
+ verbose: Print each year before its LCIA calculation.
291
+
292
+ Returns:
293
+ Two DataFrames: long-form LCIA scores and missing activity roots.
294
+ """
295
+ years, countries, foreground_databases, background_databases = (
296
+ _validate_comparison_inputs(
297
+ foreground_databases,
298
+ background_databases,
299
+ countries,
300
+ years,
301
+ background_label,
302
+ )
303
+ )
304
+ resolver = _ActivityResolver()
305
+ aliases = {**DEFAULT_COUNTRY_ALIASES, **(country_aliases or {})}
306
+ rows = []
307
+ gaps = []
308
+
309
+ for year in years:
310
+ if verbose:
311
+ print(f"Calculating {method} for {year}")
312
+ demands = {}
313
+ metadata = {}
314
+ for country in countries:
315
+ roots = _comparison_roots(
316
+ foreground_databases,
317
+ background_databases,
318
+ year,
319
+ country,
320
+ background_label,
321
+ aliases,
322
+ resolver,
323
+ )
324
+ for model, activity in roots.items():
325
+ if activity is None:
326
+ gaps.append(
327
+ {
328
+ "year": year,
329
+ "country": country,
330
+ "model": model,
331
+ }
332
+ )
333
+ continue
334
+ label = f"{year}:{country}:{model}"
335
+ demands[label] = {activity.id: 1}
336
+ metadata[label] = (country, model)
337
+
338
+ if not demands:
339
+ continue
340
+ scores = _run_multilca(demands, method)
341
+ rows.extend(
342
+ {
343
+ "year": year,
344
+ "country": country,
345
+ "model": model,
346
+ "score": scores[label],
347
+ }
348
+ for label, (country, model) in metadata.items()
349
+ )
350
+ collect()
351
+
352
+ return (
353
+ pd.DataFrame(
354
+ rows,
355
+ columns=["year", "country", "model", "score"],
356
+ ),
357
+ pd.DataFrame(
358
+ gaps,
359
+ columns=["year", "country", "model"],
360
+ ),
361
+ )
362
+
363
+
364
+ def _run_multilca(demands, method):
365
+ import bw2calc as bc
366
+
367
+ method_config = {"impact_categories": [method]}
368
+ data_objects = bd.get_multilca_data_objs(
369
+ functional_units=demands,
370
+ method_config=method_config,
371
+ )
372
+ lca = bc.MultiLCA(
373
+ demands=demands,
374
+ method_config=method_config,
375
+ data_objs=data_objects,
376
+ )
377
+ lca.lci()
378
+ lca.lcia()
379
+ scores = {
380
+ label: float(np.asarray(lca.scores[(method, label)]).reshape(-1)[0])
381
+ for label in demands
382
+ }
383
+ del lca, data_objects
384
+ return scores
385
+
386
+
387
+ def _comparison_roots(
388
+ foreground_databases,
389
+ background_databases,
390
+ year,
391
+ country,
392
+ background_label,
393
+ aliases,
394
+ resolver,
395
+ ):
396
+ roots = {
397
+ label: resolver.find_unique(
398
+ databases[year],
399
+ f"Electricity mix in {country}, {year}",
400
+ country,
401
+ )
402
+ for label, databases in foreground_databases.items()
403
+ }
404
+ roots[background_label] = resolver.find_unique(
405
+ background_databases[year],
406
+ "market for electricity, low voltage",
407
+ aliases.get(country, country),
408
+ )
409
+ return roots
410
+
411
+
412
+ def _validate_comparison_inputs(
413
+ foreground_databases,
414
+ background_databases,
415
+ countries,
416
+ years,
417
+ background_label,
418
+ ):
419
+ if not isinstance(foreground_databases, Mapping) or not foreground_databases:
420
+ raise ValueError("foreground_databases must be a non-empty mapping")
421
+ if not isinstance(background_databases, Mapping):
422
+ raise TypeError("background_databases must be a year mapping")
423
+ if not isinstance(background_label, str) or not background_label:
424
+ raise ValueError("background_label must be a non-empty string")
425
+ if background_label in foreground_databases:
426
+ raise ValueError("background_label must differ from foreground database labels")
427
+
428
+ years = tuple(int(year) for year in years)
429
+ countries = tuple(str(country) for country in countries)
430
+ if not years:
431
+ raise ValueError("years cannot be empty")
432
+ if not countries:
433
+ raise ValueError("countries cannot be empty")
434
+
435
+ normalized_foregrounds = {}
436
+ for label, databases in foreground_databases.items():
437
+ if not isinstance(label, str) or not label:
438
+ raise ValueError("foreground database labels must be non-empty strings")
439
+ normalized_foregrounds[label] = _require_year_database_mapping(
440
+ databases,
441
+ years,
442
+ f"foreground_databases[{label!r}]",
443
+ )
444
+ normalized_backgrounds = _require_year_database_mapping(
445
+ background_databases,
446
+ years,
447
+ "background_databases",
448
+ )
449
+ return years, countries, normalized_foregrounds, normalized_backgrounds
450
+
451
+
452
+ def _require_year_database_mapping(databases, years, name):
453
+ if not isinstance(databases, Mapping):
454
+ raise TypeError(f"{name} must be a year mapping")
455
+ missing = set(years).difference(databases)
456
+ if missing:
457
+ raise ValueError(
458
+ f"{name} is missing years: " + ", ".join(map(str, sorted(missing)))
459
+ )
460
+ normalized = {}
461
+ for year in years:
462
+ database_name = databases[year]
463
+ if not isinstance(database_name, str) or not database_name:
464
+ raise ValueError(f"{name}[{year}] must be a non-empty string")
465
+ normalized[year] = database_name
466
+ return normalized
467
+
468
+
469
+ def _is_electricity_activity(activity):
470
+ product = str(activity.get("reference product", "")).lower()
471
+ unit = str(activity.get("unit", "")).lower()
472
+ return "electricity" in product and unit in {"kilowatt hour", "kwh"}
473
+
474
+
475
+ def _is_generation_activity(activity):
476
+ name = str(activity.get("name", "")).lower()
477
+ return (
478
+ name.startswith("electricity production")
479
+ or "heat and power co-generation" in name
480
+ or (name.startswith("treatment of ") and "power plant" in name)
481
+ or "electricity generation" in name
482
+ )
@@ -366,6 +366,7 @@ def create_activity_dict(
366
366
  db_name,
367
367
  eidb_name=None,
368
368
  year=None,
369
+ consumption_profile=None,
369
370
  ):
370
371
  """
371
372
  Creates a dictionary of activities for the BW database based on the filtered dataframe and known inputs.
@@ -380,6 +381,8 @@ def create_activity_dict(
380
381
  year (int, optional): Model year stored on each activity and included in
381
382
  its name. If a time-indexed column already contains the year, the
382
383
  name is left unchanged to avoid repeating it.
384
+ consumption_profile (str, optional): Temporal weighting represented by
385
+ the foreground inventory, stored as activity metadata.
383
386
 
384
387
  Returns:
385
388
  dict: A dictionary containing activities to be written to the BW2 database.
@@ -420,6 +423,8 @@ def create_activity_dict(
420
423
  }
421
424
  if activity_year is not None:
422
425
  act["year"] = activity_year
426
+ if consumption_profile is not None:
427
+ act["consumption_profile"] = str(consumption_profile)
423
428
  # Add the production exchange
424
429
  act["exchanges"].append(
425
430
  {
@@ -539,6 +544,7 @@ def create_database(
539
544
  network=True,
540
545
  strict=False,
541
546
  year=None,
547
+ consumption_profile=None,
542
548
  ):
543
549
  """
544
550
  Creates an "ecoinvent-like" BW database based on a previously filtered dataframe.
@@ -555,6 +561,8 @@ def create_database(
555
561
  cannot be matched uniquely.
556
562
  year (int, optional): Model year included in foreground activity names
557
563
  and stored as activity metadata.
564
+ consumption_profile (str, optional): Temporal weighting stored as
565
+ foreground activity metadata.
558
566
 
559
567
  Returns:
560
568
  None
@@ -576,6 +584,7 @@ def create_database(
576
584
  db_name,
577
585
  eidb_name=eidb_name,
578
586
  year=year,
587
+ consumption_profile=consumption_profile,
579
588
  )
580
589
  elec_db = setup_database(project_name, db_name)
581
590
  elec_db.write(activities)