hestia-earth-models 0.64.3__py3-none-any.whl → 0.64.5__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.

Potentially problematic release.


This version of hestia-earth-models might be problematic. Click here for more details.

Files changed (76) hide show
  1. hestia_earth/models/blonkConsultants2016/ch4ToAirNaturalVegetationBurning.py +5 -9
  2. hestia_earth/models/blonkConsultants2016/co2ToAirAboveGroundBiomassStockChangeLandUseChange.py +5 -9
  3. hestia_earth/models/blonkConsultants2016/n2OToAirNaturalVegetationBurningDirect.py +6 -13
  4. hestia_earth/models/cycle/animal/input/properties.py +6 -0
  5. hestia_earth/models/cycle/completeness/soilAmendment.py +3 -2
  6. hestia_earth/models/cycle/concentrateFeed.py +10 -4
  7. hestia_earth/models/cycle/input/properties.py +6 -0
  8. hestia_earth/models/cycle/liveAnimal.py +2 -2
  9. hestia_earth/models/cycle/milkYield.py +3 -3
  10. hestia_earth/models/cycle/otherSitesArea.py +59 -0
  11. hestia_earth/models/cycle/otherSitesUnusedDuration.py +9 -8
  12. hestia_earth/models/cycle/pastureSystem.py +3 -2
  13. hestia_earth/models/cycle/product/properties.py +6 -0
  14. hestia_earth/models/cycle/siteArea.py +83 -0
  15. hestia_earth/models/cycle/stockingDensityAnimalHousingAverage.py +28 -16
  16. hestia_earth/models/cycle/utils.py +1 -1
  17. hestia_earth/models/environmentalFootprintV3/soilQualityIndexLandOccupation.py +128 -0
  18. hestia_earth/models/environmentalFootprintV3/utils.py +17 -0
  19. hestia_earth/models/fantkeEtAl2016/__init__.py +13 -0
  20. hestia_earth/models/fantkeEtAl2016/damageToHumanHealthParticulateMatterFormation.py +49 -0
  21. hestia_earth/models/frischknechtEtAl2000/__init__.py +13 -0
  22. hestia_earth/models/frischknechtEtAl2000/ionisingRadiationKbqU235Eq.py +90 -0
  23. hestia_earth/models/ipcc2006/co2ToAirOrganicSoilCultivation.py +17 -6
  24. hestia_earth/models/ipcc2006/n2OToAirOrganicSoilCultivationDirect.py +17 -6
  25. hestia_earth/models/ipcc2019/animal/liveweightGain.py +4 -3
  26. hestia_earth/models/ipcc2019/animal/liveweightPerHead.py +4 -3
  27. hestia_earth/models/ipcc2019/animal/weightAtMaturity.py +5 -4
  28. hestia_earth/models/ipcc2019/co2ToAirCarbonStockChange_utils.py +904 -0
  29. hestia_earth/models/ipcc2019/co2ToAirSoilOrganicCarbonStockChangeManagementChange.py +70 -618
  30. hestia_earth/models/mocking/search-results.json +390 -318
  31. hestia_earth/models/pooreNemecek2018/saplings.py +10 -7
  32. hestia_earth/models/site/management.py +18 -14
  33. hestia_earth/models/site/soilMeasurement.py +2 -2
  34. hestia_earth/models/utils/__init__.py +38 -0
  35. hestia_earth/models/utils/array_builders.py +63 -52
  36. hestia_earth/models/utils/blank_node.py +137 -82
  37. hestia_earth/models/utils/descriptive_stats.py +3 -239
  38. hestia_earth/models/utils/emission.py +6 -2
  39. hestia_earth/models/utils/feedipedia.py +15 -2
  40. hestia_earth/models/utils/impact_assessment.py +10 -5
  41. hestia_earth/models/utils/landCover.py +9 -0
  42. hestia_earth/models/utils/lookup.py +16 -3
  43. hestia_earth/models/utils/measurement.py +3 -28
  44. hestia_earth/models/utils/stats.py +429 -0
  45. hestia_earth/models/utils/term.py +15 -3
  46. hestia_earth/models/utils/time_series.py +90 -0
  47. hestia_earth/models/version.py +1 -1
  48. {hestia_earth_models-0.64.3.dist-info → hestia_earth_models-0.64.5.dist-info}/METADATA +1 -1
  49. {hestia_earth_models-0.64.3.dist-info → hestia_earth_models-0.64.5.dist-info}/RECORD +76 -54
  50. tests/models/blonkConsultants2016/test_ch4ToAirNaturalVegetationBurning.py +2 -2
  51. tests/models/blonkConsultants2016/test_co2ToAirAboveGroundBiomassStockChangeLandUseChange.py +2 -2
  52. tests/models/blonkConsultants2016/test_n2OToAirNaturalVegetationBurningDirect.py +2 -2
  53. tests/models/cycle/completeness/test_soilAmendment.py +1 -1
  54. tests/models/cycle/test_liveAnimal.py +1 -1
  55. tests/models/cycle/test_milkYield.py +1 -1
  56. tests/models/cycle/test_otherSitesArea.py +68 -0
  57. tests/models/cycle/test_siteArea.py +51 -0
  58. tests/models/cycle/test_stockingDensityAnimalHousingAverage.py +2 -2
  59. tests/models/environmentalFootprintV3/test_soilQualityIndexLandOccupation.py +136 -0
  60. tests/models/fantkeEtAl2016/__init__.py +0 -0
  61. tests/models/fantkeEtAl2016/test_damageToHumanHealthParticulateMatterFormation.py +20 -0
  62. tests/models/frischknechtEtAl2000/__init__.py +0 -0
  63. tests/models/frischknechtEtAl2000/test_ionisingRadiationKbqU235Eq.py +70 -0
  64. tests/models/ipcc2019/test_co2ToAirCarbonStockChange_utils.py +50 -0
  65. tests/models/ipcc2019/test_co2ToAirSoilOrganicCarbonStockChangeManagementChange.py +1 -39
  66. tests/models/pooreNemecek2018/test_saplings.py +1 -1
  67. tests/models/site/test_management.py +3 -153
  68. tests/models/utils/test_array_builders.py +67 -6
  69. tests/models/utils/test_blank_node.py +191 -7
  70. tests/models/utils/test_descriptive_stats.py +2 -86
  71. tests/models/utils/test_measurement.py +1 -22
  72. tests/models/utils/test_stats.py +186 -0
  73. tests/models/utils/test_time_series.py +88 -0
  74. {hestia_earth_models-0.64.3.dist-info → hestia_earth_models-0.64.5.dist-info}/LICENSE +0 -0
  75. {hestia_earth_models-0.64.3.dist-info → hestia_earth_models-0.64.5.dist-info}/WHEEL +0 -0
  76. {hestia_earth_models-0.64.3.dist-info → hestia_earth_models-0.64.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,904 @@
1
+ """
2
+ Utilities for calculating CO2 emissions based on changes in carbon stocks (e.g., `organicCarbonPerHa`,
3
+ `aboveGroundBiomass` and `belowGroundBiomass`).
4
+ """
5
+
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from functools import reduce
9
+ from itertools import product
10
+ from numpy import array, random, mean
11
+ from numpy.typing import NDArray
12
+ from pydash.objects import merge
13
+ from typing import NamedTuple, Optional, Union
14
+
15
+ from hestia_earth.schema import EmissionMethodTier, MeasurementMethodClassification
16
+ from hestia_earth.utils.date import diff_in_days, YEAR
17
+ from hestia_earth.utils.tools import flatten, non_empty_list, safe_parse_date
18
+
19
+ from hestia_earth.models.log import log_as_table
20
+ from hestia_earth.models.utils import pairwise
21
+ from hestia_earth.models.utils.array_builders import correlated_normal_2d
22
+ from hestia_earth.models.utils.blank_node import (
23
+ _gapfill_datestr, DatestrGapfillMode, group_nodes_by_year, split_node_by_dates
24
+ )
25
+ from hestia_earth.models.utils.constant import Units, get_atomic_conversion
26
+ from hestia_earth.models.utils.emission import min_emission_method_tier
27
+ from hestia_earth.models.utils.measurement import (
28
+ group_measurements_by_method_classification, min_measurement_method_classification,
29
+ to_measurement_method_classification
30
+ )
31
+ from hestia_earth.models.utils.time_series import (
32
+ calc_tau, compute_time_series_correlation_matrix, exponential_decay
33
+ )
34
+
35
+ _MAX_CORRELATION = 1
36
+ _MIN_CORRELATION = 0.5
37
+ _NOMINAL_ERROR = 75
38
+ """
39
+ carbon stock measurements without an associated `sd` should be assigned a nominal error of 75% (2*sd as a percentage of
40
+ the mean).
41
+ """
42
+ _TRANSITION_PERIOD = 20 * YEAR # 20 years in days
43
+ _VALID_MEASUREMENT_METHOD_CLASSIFICATIONS = [
44
+ MeasurementMethodClassification.ON_SITE_PHYSICAL_MEASUREMENT,
45
+ MeasurementMethodClassification.MODELLED_USING_OTHER_MEASUREMENTS,
46
+ MeasurementMethodClassification.TIER_3_MODEL,
47
+ MeasurementMethodClassification.TIER_2_MODEL,
48
+ MeasurementMethodClassification.TIER_1_MODEL
49
+ ]
50
+ """
51
+ The list of `MeasurementMethodClassification`s that can be used to calculate carbon stock change emissions, ranked in
52
+ order from strongest to weakest.
53
+ """
54
+
55
+
56
+ class _InventoryKey(Enum):
57
+ """
58
+ The inner keys of the annualised inventory created by the `_compile_inventory` function.
59
+
60
+ The value of each enum member is formatted to be used as a column header in the `log_as_table` function.
61
+ """
62
+ CARBON_STOCK = "carbon-stock"
63
+ CARBON_STOCK_CHANGE = "carbon-stock-change"
64
+ CO2_EMISSION = "carbon-emission"
65
+ SHARE_OF_EMISSION = "share-of-emissions"
66
+
67
+
68
+ CarbonStock = NamedTuple("CarbonStock", [
69
+ ("value", NDArray),
70
+ ("date", str),
71
+ ("method", MeasurementMethodClassification)
72
+ ])
73
+ """
74
+ NamedTuple representing a carbon stock (e.g., `organicCarbonPerHa` or `aboveGroundBiomass`).
75
+
76
+ Attributes
77
+ ----------
78
+ value : NDArray
79
+ The value of the carbon stock measurement (kg C ha-1).
80
+ date : str
81
+ The date of the measurement as a datestr with the format `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm:ss`.
82
+ method: MeasurementMethodClassification
83
+ The measurement method for the carbon stock.
84
+ """
85
+
86
+
87
+ CarbonStockChange = NamedTuple("CarbonStockChange", [
88
+ ("value", NDArray),
89
+ ("start_date", str),
90
+ ("end_date", str),
91
+ ("method", MeasurementMethodClassification)
92
+ ])
93
+ """
94
+ NamedTuple representing a carbon stock change.
95
+
96
+ Attributes
97
+ ----------
98
+ value : NDArray
99
+ The value of the carbon stock change (kg C ha-1).
100
+ start_date : str
101
+ The start date of the carbon stock change event as a datestr with the format `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or
102
+ `YYYY-MM-DDTHH:mm:ss`.
103
+ end_date : str
104
+ The end date of the carbon stock change event as a datestr with the format `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or
105
+ `YYYY-MM-DDTHH:mm:ss`.
106
+ method: MeasurementMethodClassification
107
+ The measurement method for the carbon stock change.
108
+ """
109
+
110
+
111
+ CarbonStockChangeEmission = NamedTuple("CarbonStockChangeEmission", [
112
+ ("value", NDArray),
113
+ ("start_date", str),
114
+ ("end_date", str),
115
+ ("method", EmissionMethodTier)
116
+ ])
117
+ """
118
+ NamedTuple representing a carbon stock change emission.
119
+
120
+ Attributes
121
+ ----------
122
+ value : NDArray
123
+ The value of the carbon stock change emission (kg CO2 ha-1).
124
+ start_date : str
125
+ The start date of the carbon stock change emission as a datestr with the format `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or
126
+ `YYYY-MM-DDTHH:mm:ss`.
127
+ end_date : str
128
+ The end date of the carbon stock change emission as a datestr with the format `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or
129
+ `YYYY-MM-DDTHH:mm:ss`.
130
+ method: MeasurementMethodClassification
131
+ The emission method tier.
132
+ """
133
+
134
+
135
+ def lerp_carbon_stocks(start: CarbonStock, end: CarbonStock, target_date: str) -> CarbonStock:
136
+ """
137
+ Estimate, using linear interpolation, a carbon stock for a specific date based on the carbon stocks of two other
138
+ dates.
139
+
140
+ Parameters
141
+ ----------
142
+ start : CarbonStock
143
+ The `CarbonStock` at the start (kg C ha-1).
144
+ end : CarbonStock
145
+ The `CarbonStock` at the end (kg C ha-1).
146
+ target_date : str
147
+ The target date for interpolation as a datestr with format `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or
148
+ `YYYY-MM-DDTHH:mm:ss`.
149
+
150
+ Returns
151
+ -------
152
+ CarbonStock
153
+ The interpolated `CarbonStock` for the specified date (kg C ha-1).
154
+ """
155
+ alpha = diff_in_days(start.date, target_date) / diff_in_days(start.date, end.date)
156
+ value = (1 - alpha) * start.value + alpha * end.value
157
+ method = min_measurement_method_classification(start.method, end.method)
158
+ return CarbonStock(value, target_date, method)
159
+
160
+
161
+ def calc_carbon_stock_change(start: CarbonStock, end: CarbonStock) -> CarbonStockChange:
162
+ """
163
+ Calculate the change in a carbon stock between two different dates.
164
+
165
+ The method should be the weaker of the two `MeasurementMethodClassification`s.
166
+
167
+ Parameters
168
+ ----------
169
+ start : CarbonStock
170
+ The carbon stock at the start (kg C ha-1).
171
+ end : CarbonStock
172
+ The carbon stock at the end (kg C ha-1).
173
+
174
+ Returns
175
+ -------
176
+ CarbonStockChange
177
+ The carbon stock change (kg C ha-1).
178
+ """
179
+ value = end.value - start.value
180
+ method = min_measurement_method_classification(start.method, end.method)
181
+ return CarbonStockChange(value, start.date, end.date, method)
182
+
183
+
184
+ def calc_carbon_stock_change_emission(carbon_stock_change: CarbonStockChange) -> CarbonStockChangeEmission:
185
+ """
186
+ Convert a `CarbonStockChange` into a `CarbonStockChangeEmission`.
187
+
188
+ Parameters
189
+ ----------
190
+ carbon_stock_change : CarbonStockChange
191
+ The carbon stock change (kg C ha-1).
192
+
193
+ Returns
194
+ -------
195
+ CarbonStockChangeEmission
196
+ The carbon stock change emission (kg CO2 ha-1).
197
+ """
198
+ value = _convert_c_to_co2(carbon_stock_change.value) * -1
199
+ method = _convert_mmc_to_emt(carbon_stock_change.method)
200
+ return CarbonStockChangeEmission(value, carbon_stock_change.start_date, carbon_stock_change.end_date, method)
201
+
202
+
203
+ def _convert_mmc_to_emt(
204
+ measurement_method_classification: MeasurementMethodClassification
205
+ ) -> EmissionMethodTier:
206
+ """
207
+ Get the emission method tier based on the provided measurement method classification.
208
+
209
+ Parameters
210
+ ----------
211
+ measurement_method_classification : MeasurementMethodClassification
212
+ The measurement method classification to convert into the corresponding emission method tier.
213
+
214
+ Returns
215
+ -------
216
+ EmissionMethodTier
217
+ The corresponding emission method tier.
218
+ """
219
+ return _MEASUREMENT_METHOD_CLASSIFICATION_TO_EMISSION_METHOD_TIER.get(
220
+ to_measurement_method_classification(measurement_method_classification),
221
+ _DEFAULT_EMISSION_METHOD_TIER
222
+ )
223
+
224
+
225
+ _DEFAULT_EMISSION_METHOD_TIER = EmissionMethodTier.TIER_1
226
+ _MEASUREMENT_METHOD_CLASSIFICATION_TO_EMISSION_METHOD_TIER = {
227
+ MeasurementMethodClassification.TIER_2_MODEL: EmissionMethodTier.TIER_2,
228
+ MeasurementMethodClassification.TIER_3_MODEL: EmissionMethodTier.TIER_3,
229
+ MeasurementMethodClassification.MODELLED_USING_OTHER_MEASUREMENTS: EmissionMethodTier.MEASURED,
230
+ MeasurementMethodClassification.ON_SITE_PHYSICAL_MEASUREMENT: EmissionMethodTier.MEASURED,
231
+ }
232
+ """
233
+ A mapping between `MeasurementMethodClassification`s and `EmissionMethodTier`s. As carbon stock measurements can be
234
+ measured/estimated through a variety of methods, the emission model needs be able to assign an emission tier for each.
235
+ Any `MeasurementMethodClassification` not in the mapping should be assigned `DEFAULT_EMISSION_METHOD_TIER`.
236
+ """
237
+
238
+
239
+ def _convert_c_to_co2(kg_c: float) -> float:
240
+ """
241
+ Convert mass of carbon (C) to carbon dioxide (CO2) using the atomic conversion ratio.
242
+
243
+ n.b. `get_atomic_conversion` returns the ratio C:CO2 (~44/12).
244
+
245
+ Parameters
246
+ ----------
247
+ kg_c : float
248
+ Mass of carbon (C) to be converted to carbon dioxide (CO2) (kg C).
249
+
250
+ Returns
251
+ -------
252
+ float
253
+ Mass of carbon dioxide (CO2) resulting from the conversion (kg CO2).
254
+ """
255
+ return kg_c * get_atomic_conversion(Units.KG_CO2, Units.TO_C)
256
+
257
+
258
+ def rescale_carbon_stock_change_emission(
259
+ emission: CarbonStockChangeEmission, factor: float
260
+ ) -> CarbonStockChangeEmission:
261
+ """
262
+ Rescale a `CarbonStockChangeEmission` by a specified factor.
263
+
264
+ Parameters
265
+ ----------
266
+ emission : CarbonStockChangeEmission
267
+ A carbon stock change emission (kg CO2 ha-1).
268
+ factor : float
269
+ A scaling factor, representing a proportion of the total emission as a decimal. (e.g., a
270
+ [Cycles](https://www.hestia.earth/schema/Cycle)'s share of an annual emission).
271
+
272
+ Returns
273
+ -------
274
+ CarbonStockChangeEmission
275
+ The rescaled emission.
276
+ """
277
+ value = emission.value * factor
278
+ return CarbonStockChangeEmission(value, emission.start_date, emission.end_date, emission.method)
279
+
280
+
281
+ def add_carbon_stock_change_emissions(
282
+ emission_1: CarbonStockChangeEmission, emission_2: CarbonStockChangeEmission
283
+ ) -> CarbonStockChangeEmission:
284
+ """
285
+ Sum together multiple `CarbonStockChangeEmission`s.
286
+
287
+ Parameters
288
+ ----------
289
+ emission_1 : CarbonStockChangeEmission
290
+ A carbon stock change emission (kg CO2 ha-1).
291
+ emission_2 : CarbonStockChangeEmission
292
+ A carbon stock change emission (kg CO2 ha-1).
293
+
294
+ Returns
295
+ -------
296
+ CarbonStockChangeEmission
297
+ The summed emission.
298
+ """
299
+ value = emission_1.value + emission_2.value
300
+ start_date = min(emission_1.start_date, emission_2.start_date)
301
+ end_date = max(emission_1.end_date, emission_2.end_date)
302
+ method = min_emission_method_tier(emission_1.method, emission_2.method)
303
+
304
+ return CarbonStockChangeEmission(value, start_date, end_date, method)
305
+
306
+
307
+ def compile_inventory(
308
+ cycle_id: str,
309
+ cycles: list[dict],
310
+ carbon_stock_measurements: list[dict],
311
+ iterations: int = 10000,
312
+ seed: Union[int, random.Generator, None] = None
313
+ ) -> tuple[dict, dict]:
314
+ """
315
+ Compile an annual inventory of carbon stocks, changes in carbon stocks, carbon stock change emissions, and the share
316
+ of emissions of cycles based on the provided cycles and measurement data.
317
+
318
+ A separate inventory is compiled for each valid `MeasurementMethodClassification` present in the data, and the
319
+ strongest available method is chosen for each relevant inventory year. These inventories are then merged into one
320
+ final result.
321
+
322
+ The final inventory structure is:
323
+ ```
324
+ {
325
+ year (int): {
326
+ _InventoryKey.CARBON_STOCK: value (CarbonStock),
327
+ _InventoryKey.CARBON_STOCK_CHANGE: value (CarbonStockChange),
328
+ _InventoryKey.CO2_EMISSION: value (CarbonStockChangeEmission),
329
+ _InventoryKey.SHARE_OF_EMISSION: {
330
+ cycle_id (str): value (float),
331
+ ...cycle_ids
332
+ }
333
+ },
334
+ ...years
335
+ }
336
+ ```
337
+
338
+ Parameters
339
+ ----------
340
+ cycle_id : str
341
+ The unique identifier of the cycle being processed.
342
+ cycles : list[dict]
343
+ A list of cycle data dictionaries, each representing land management events or cycles, grouped by years.
344
+ carbon_stock_measurements: list[dict]
345
+ A list of dictionaries, each representing carbon stock measurements across time and methods.
346
+ iterations : int, optional
347
+ The number of iterations for stochastic processing (default is 10,000).
348
+ seed : int, random.Generator, or None, optional
349
+ Seed for random number generation to ensure reproducibility. Default is None.
350
+
351
+
352
+ Returns
353
+ -------
354
+ tuple[dict, dict]
355
+ `(inventory, logs)`
356
+ """
357
+ # Process cycles and carbon stock measurements independently
358
+ cycle_inventory = _compile_cycle_inventory(cycles)
359
+ carbon_stock_inventory = _compile_carbon_stock_inventory(
360
+ carbon_stock_measurements, iterations=iterations, seed=seed
361
+ )
362
+
363
+ # Generate logs without side-effects
364
+ logs = _generate_logs(cycle_inventory, carbon_stock_inventory)
365
+
366
+ # Combine the inventories functionally
367
+ inventory = _squash_inventory(cycle_id, cycle_inventory, carbon_stock_inventory)
368
+
369
+ return inventory, logs
370
+
371
+
372
+ def _compile_cycle_inventory(cycles: list[dict]) -> dict:
373
+ """
374
+ Calculate grouped share of emissions for cycles based on the amount they contribute the the overall land management
375
+ of an inventory year.
376
+
377
+ This function groups cycles by year, then calculates the share of emissions for each cycle based on the
378
+ `fraction_of_group_duration` value. The share of emissions is normalized by the sum of cycle occupancies for the
379
+ entire dataset to ensure the values represent a valid share.
380
+
381
+ The returned inventory has the shape:
382
+ ```
383
+ {
384
+ year (int): {
385
+ _InventoryKey.SHARE_OF_EMISSION: {
386
+ cycle_id (str): value (float),
387
+ ...cycle_ids
388
+ }
389
+ },
390
+ ...more years
391
+ }
392
+ ```
393
+
394
+ Parameters
395
+ ----------
396
+ cycles : list[dict]
397
+ List of [Cycle nodes](https://www.hestia.earth/schema/Cycle), where each cycle dictionary should contain a
398
+ "fraction_of_group_duration" key added by the `group_nodes_by_year` function.
399
+ iterations : int, optional
400
+ Number of iterations for stochastic sampling when processing carbon stock values (default is 10,000).
401
+ seed : int, random.Generator, or None, optional
402
+ Seed for random number generation (default is None).
403
+
404
+ Returns
405
+ -------
406
+ dict
407
+ A dictionary with grouped share of emissions for each cycle based on the fraction of the year.
408
+ """
409
+ grouped_cycles = group_nodes_by_year(cycles)
410
+
411
+ def calculate_emissions(cycles_in_year):
412
+ total_fraction = sum(c.get("fraction_of_group_duration", 0) for c in cycles_in_year)
413
+ return {
414
+ cycle["@id"]: cycle.get("fraction_of_group_duration", 0) / total_fraction
415
+ for cycle in cycles_in_year
416
+ }
417
+
418
+ return {
419
+ year: {_InventoryKey.SHARE_OF_EMISSION: calculate_emissions(cycles_in_year)}
420
+ for year, cycles_in_year in grouped_cycles.items()
421
+ }
422
+
423
+
424
+ def _compile_carbon_stock_inventory(
425
+ carbon_stock_measurements: list[dict],
426
+ iterations: int = 10000,
427
+ seed: Union[int, random.Generator, None] = None
428
+ ) -> dict:
429
+ """
430
+ Compile an annual inventory of carbon stock data and pre-computed carbon stock change emissions.
431
+
432
+ Carbon stock measurements are grouped by the method used (MeasurementMethodClassification). For each method,
433
+ carbon stocks are processed for each year and changes between years are computed, followed by the calculation of
434
+ CO2 emissions.
435
+
436
+ The returned inventory has the shape:
437
+ ```
438
+ {
439
+ method (MeasurementMethodClassification): {
440
+ year (int): {
441
+ _InventoryKey.CARBON_STOCK: value (CarbonStock),
442
+ _InventoryKey.CARBON_STOCK_CHANGE: value (CarbonStockChange),
443
+ _InventoryKey.CO2_EMISSION: value (CarbonStockChangeEmission)
444
+ },
445
+ ...more years
446
+ }
447
+ ...more methods
448
+ }
449
+ ```
450
+
451
+ Parameters
452
+ ----------
453
+ carbon_stock_measurements : list[dict]
454
+ List of carbon [Measurement nodes](https://www.hestia.earth/schema/Measurement) nodes.
455
+ iterations : int, optional
456
+ Number of iterations for stochastic sampling when processing carbon stock values (default is 10,000).
457
+ seed : int, random.Generator, or None, optional
458
+ Seed for random number generation (default is None).
459
+
460
+ Returns
461
+ -------
462
+ dict
463
+ The carbon stock inventory grouped by measurement method classification.
464
+ """
465
+ carbon_stock_measurements_by_method = group_measurements_by_method_classification(carbon_stock_measurements)
466
+
467
+ return {
468
+ method: _process_carbon_stock_measurements(measurements, iterations=iterations, seed=seed)
469
+ for method, measurements in carbon_stock_measurements_by_method.items()
470
+ }
471
+
472
+
473
+ def _process_carbon_stock_measurements(
474
+ carbon_stock_measurements: list[dict],
475
+ iterations: int = 10000,
476
+ seed: Union[int, random.Generator, None] = None
477
+ ) -> dict:
478
+ """
479
+ Process carbon stock measurements to compile an annual inventory of carbon stocks, carbon stock changes, and CO2
480
+ emissions. The inventory is built by interpolating between measured values and calculating changes across years.
481
+
482
+ The returned inventory has the shape:
483
+ ```
484
+ {
485
+ year (int): {
486
+ _InventoryKey.CARBON_STOCK: value (CarbonStock),
487
+ _InventoryKey.CARBON_STOCK_CHANGE: value (CarbonStockChange),
488
+ _InventoryKey.CO2_EMISSION: value (CarbonStockChangeEmission)
489
+ },
490
+ ...more years
491
+ }
492
+ ```
493
+
494
+ Parameters
495
+ ----------
496
+ carbon_stock_measurements : list[dict]
497
+ List of pre-validated carbon stock [Measurement nodes](https://www.hestia.earth/schema/Measurement).
498
+ iterations : int, optional
499
+ Number of iterations for stochastic sampling when processing carbon stock values (default is 10,000).
500
+ seed : int, random.Generator, or None, optional
501
+ Seed for random number generation (default is None).
502
+
503
+ Returns
504
+ -------
505
+ dict
506
+ The annual inventory.
507
+ """
508
+ carbon_stocks = _preprocess_carbon_stocks(carbon_stock_measurements, iterations, seed)
509
+
510
+ carbon_stocks_by_year = _interpolate_carbon_stocks(carbon_stocks)
511
+ carbon_stock_changes_by_year = _calculate_stock_changes(carbon_stocks_by_year)
512
+ co2_emissions_by_year = _calculate_co2_emissions(carbon_stock_changes_by_year)
513
+
514
+ return _sorted_merge(carbon_stocks_by_year, carbon_stock_changes_by_year, co2_emissions_by_year)
515
+
516
+
517
+ def _preprocess_carbon_stocks(
518
+ carbon_stock_measurements: list[dict],
519
+ iterations: int = 10000,
520
+ seed: Union[int, random.Generator, None] = None
521
+ ) -> list[CarbonStock]:
522
+ """
523
+ Pre-process a list of carbon stock measurements by normalizing and sorting them by date. The measurements are used
524
+ to create correlated samples using stochastic sampling methods.
525
+
526
+ The carbon stock measurements are processed to fill in any gaps in data (e.g., missing standard deviations), and
527
+ correlated samples are drawn to handle measurement uncertainty.
528
+
529
+ Parameters
530
+ ----------
531
+ carbon_stock_measurements : list[dict]
532
+ List of pre-validated carbon stock [Measurement nodes](https://www.hestia.earth/schema/Measurement).
533
+ iterations : int, optional
534
+ Number of iterations for stochastic sampling when processing carbon stock values (default is 10,000).
535
+ seed : int, random.Generator, or None, optional
536
+ Seed for random number generation (default is None).
537
+
538
+ Returns
539
+ -------
540
+ list[CarbonStock]
541
+ A list of carbon stocks sorted by date.
542
+ """
543
+ sorted_measurements = sorted(
544
+ flatten([split_node_by_dates(m) for m in carbon_stock_measurements]),
545
+ key=lambda node: _gapfill_datestr(node["dates"][0], DatestrGapfillMode.END)
546
+ )
547
+
548
+ values = flatten(node["value"] for node in sorted_measurements)
549
+
550
+ sds = flatten(
551
+ node.get("sd", []) or [_calc_nominal_sd(v, _NOMINAL_ERROR) for v in node["value"]]
552
+ for node in sorted_measurements
553
+ )
554
+
555
+ dates = flatten(
556
+ [_gapfill_datestr(datestr, DatestrGapfillMode.END) for datestr in node["dates"]]
557
+ for node in sorted_measurements
558
+ )
559
+
560
+ methods = flatten(
561
+ [MeasurementMethodClassification(node.get("methodClassification")) for _ in node["value"]]
562
+ for node in sorted_measurements
563
+ )
564
+
565
+ correlation_matrix = compute_time_series_correlation_matrix(
566
+ dates,
567
+ decay_fn=lambda dt: exponential_decay(
568
+ dt,
569
+ tau=calc_tau(_TRANSITION_PERIOD),
570
+ initial_value=_MAX_CORRELATION,
571
+ final_value=_MIN_CORRELATION
572
+ )
573
+ )
574
+
575
+ correlated_samples = correlated_normal_2d(
576
+ iterations,
577
+ array(values),
578
+ array(sds),
579
+ correlation_matrix,
580
+ seed=seed
581
+ )
582
+
583
+ return [
584
+ CarbonStock(value=sample, date=date, method=method)
585
+ for sample, date, method in zip(correlated_samples, dates, methods)
586
+ ]
587
+
588
+
589
+ def _calc_nominal_sd(value: float, error: float) -> float:
590
+ """
591
+ Calculate a nominal SD for a carbon stock measurement. Can be used to gap fill SD when information not present in
592
+ measurement node.
593
+ """
594
+ return value * error / 200
595
+
596
+
597
+ def _interpolate_carbon_stocks(carbon_stocks: list[CarbonStock]) -> dict:
598
+ """
599
+ Interpolate between carbon stock measurements to estimate annual carbon stocks.
600
+
601
+ The function takes a list of carbon stock measurements and interpolates between pairs of consecutive measurements
602
+ to estimate the carbon stock values for each year in between.
603
+
604
+ The returned dictionary has the format:
605
+ ```
606
+ {
607
+ year (int): {
608
+ _InventoryKey.CARBON_STOCK: value (CarbonStock),
609
+ },
610
+ ...more years
611
+ }
612
+ ```
613
+ """
614
+ def interpolate_between(result: dict, carbon_stock_pair: tuple[CarbonStock, CarbonStock]) -> dict:
615
+ start, end = carbon_stock_pair[0], carbon_stock_pair[1]
616
+
617
+ start_date = safe_parse_date(start.date, datetime.min)
618
+ end_date = safe_parse_date(end.date, datetime.min)
619
+
620
+ should_run = (
621
+ datetime.min != start_date != end_date
622
+ and end_date > start_date
623
+ )
624
+
625
+ update = {
626
+ year: {_InventoryKey.CARBON_STOCK: lerp_carbon_stocks(
627
+ start,
628
+ end,
629
+ f"{year}-12-31T23:59:59"
630
+ )} for year in range(start_date.year, end_date.year+1)
631
+ } if should_run else {}
632
+
633
+ return result | update
634
+
635
+ return reduce(interpolate_between, pairwise(carbon_stocks), dict())
636
+
637
+
638
+ def _calculate_stock_changes(carbon_stocks_by_year: dict) -> dict:
639
+ """
640
+ Calculate the change in carbon stock between consecutive years.
641
+
642
+ The function takes a dictionary of carbon stock values keyed by year and computes the difference between the
643
+ carbon stock for each year and the previous year. The result is stored as a `CarbonStockChange` object.
644
+
645
+ The returned dictionary has the format:
646
+ ```
647
+ {
648
+ year (int): {
649
+ _InventoryKey.CARBON_STOCK_CHANGE: value (CarbonStockChange),
650
+ },
651
+ ...more years
652
+ }
653
+ ```
654
+ """
655
+ return {
656
+ year: {
657
+ _InventoryKey.CARBON_STOCK_CHANGE: calc_carbon_stock_change(
658
+ start_group[_InventoryKey.CARBON_STOCK],
659
+ end_group[_InventoryKey.CARBON_STOCK]
660
+ )
661
+ } for (_, start_group), (year, end_group) in pairwise(carbon_stocks_by_year.items())
662
+ }
663
+
664
+
665
+ def _calculate_co2_emissions(carbon_stock_changes_by_year: dict) -> dict:
666
+ """
667
+ Calculate CO2 emissions from changes in carbon stock between consecutive years.
668
+
669
+ The function takes a dictionary of carbon stock changes and calculates the corresponding CO2 emissions for each
670
+ year using a predefined emission factor.
671
+
672
+ The returned dictionary has the format:
673
+ ```
674
+ {
675
+ year (int): {
676
+ _InventoryKey.CO2_EMISSION: value (CarbonStockChangeEmission),
677
+ },
678
+ ...more years
679
+ }
680
+ ```
681
+ """
682
+ return {
683
+ year: {
684
+ _InventoryKey.CO2_EMISSION: calc_carbon_stock_change_emission(
685
+ group[_InventoryKey.CARBON_STOCK_CHANGE]
686
+ )
687
+ } for year, group in carbon_stock_changes_by_year.items()
688
+ }
689
+
690
+
691
+ def _sorted_merge(*sources: Union[dict, list[dict]]) -> dict:
692
+ """
693
+ Merge one or more dictionaries into a single dictionary, ensuring that the keys are sorted in temporal order.
694
+
695
+ Parameters
696
+ ----------
697
+ *sources : dict | list[dict]
698
+ One or more dictionaries or lists of dictionaries to be merged.
699
+
700
+ Returns
701
+ -------
702
+ dict
703
+ A new dictionary containing the merged key-value pairs, with keys sorted.
704
+ """
705
+
706
+ _sources = non_empty_list(
707
+ flatten([arg if isinstance(arg, list) else [arg] for arg in sources])
708
+ )
709
+
710
+ merged = reduce(merge, _sources, {})
711
+ return dict(sorted(merged.items()))
712
+
713
+
714
+ def _squash_inventory(cycle_id: str, cycle_inventory: dict, carbon_stock_inventory: dict) -> dict:
715
+ """
716
+ Combine the `cycle_inventory` and `carbon_stock_inventory` into a single inventory by merging data for each year
717
+ using the strongest available `MeasurementMethodClassification`. Any years not relevant to the cycle identified
718
+ by `cycle_id` are excluded.
719
+
720
+ Parameters
721
+ ----------
722
+ cycle_id : str
723
+ The unique identifier of the cycle being processed.
724
+ cycle_inventory : dict
725
+ A dictionary representing the share of emissions for each cycle, grouped by year.
726
+ Format:
727
+ ```
728
+ {
729
+ year (int): {
730
+ _InventoryKey.SHARE_OF_EMISSION: {
731
+ cycle_id (str): value (float),
732
+ ...other cycle_ids
733
+ }
734
+ },
735
+ ...more years
736
+ }
737
+ ```
738
+ carbon_stock_inventory : dict
739
+ A dictionary representing carbon stock and emissions data grouped by measurement method and year.
740
+ Format:
741
+ ```
742
+ {
743
+ method (MeasurementMethodClassification): {
744
+ year (int): {
745
+ _InventoryKey.CARBON_STOCK: value (CarbonStock),
746
+ _InventoryKey.CARBON_STOCK_CHANGE: value (CarbonStockChange),
747
+ _InventoryKey.CO2_EMISSION: value (CarbonStockChangeEmission)
748
+ },
749
+ ...more years
750
+ },
751
+ ...more methods
752
+ }
753
+ ```
754
+
755
+ Returns
756
+ -------
757
+ dict
758
+ A combined inventory that merges cycle and carbon stock inventories for relevant years and cycles.
759
+ The resulting structure is:
760
+ ```
761
+ {
762
+ year (int): {
763
+ _InventoryKey.CARBON_STOCK: value (CarbonStock),
764
+ _InventoryKey.CARBON_STOCK_CHANGE: value (CarbonStockChange),
765
+ _InventoryKey.CO2_EMISSION: value (CarbonStockChangeEmission),
766
+ _InventoryKey.SHARE_OF_EMISSION: {
767
+ cycle_id (str): value (float),
768
+ ...other cycle_ids
769
+ }
770
+ },
771
+ ...more years
772
+ }
773
+ ```
774
+ """
775
+ inventory_years = sorted(set(non_empty_list(
776
+ flatten(list(years) for years in carbon_stock_inventory.values())
777
+ + list(cycle_inventory.keys())
778
+ )))
779
+
780
+ def should_run_group(method: MeasurementMethodClassification, year: int) -> bool:
781
+ carbon_stock_inventory_group = carbon_stock_inventory.get(method, {}).get(year, {})
782
+ share_of_emissions_group = cycle_inventory.get(year, {})
783
+
784
+ has_emission = _InventoryKey.CO2_EMISSION in carbon_stock_inventory_group.keys()
785
+ is_relevant_for_cycle = cycle_id in share_of_emissions_group.get(_InventoryKey.SHARE_OF_EMISSION, {}).keys()
786
+ return all([has_emission, is_relevant_for_cycle])
787
+
788
+ def squash(result: dict, year: int) -> dict:
789
+ update_dict = next(
790
+ (
791
+ {year: reduce(merge, [carbon_stock_inventory[method][year], cycle_inventory[year]], dict())}
792
+ for method in _VALID_MEASUREMENT_METHOD_CLASSIFICATIONS if should_run_group(method, year)
793
+ ),
794
+ {}
795
+ )
796
+ return result | update_dict
797
+
798
+ return reduce(squash, inventory_years, dict())
799
+
800
+
801
+ def _generate_logs(cycle_inventory: dict, carbon_stock_inventory: dict) -> dict:
802
+ """
803
+ Generate logs for the compiled inventory, providing details about cycle and carbon inventories.
804
+
805
+ Parameters
806
+ ----------
807
+ cycle_inventory : dict
808
+ The compiled cycle inventory.
809
+ carbon_stock_inventory : dict
810
+ The compiled carbon stock inventory.
811
+
812
+ Returns
813
+ -------
814
+ dict
815
+ A dictionary containing formatted log entries for cycle and carbon inventories.
816
+ """
817
+ logs = {
818
+ "cycle_inventory": _format_cycle_inventory(cycle_inventory),
819
+ "carbon_stock_inventory": _format_carbon_stock_inventory(carbon_stock_inventory),
820
+ }
821
+ return logs
822
+
823
+
824
+ def _format_cycle_inventory(cycle_inventory: dict) -> str:
825
+ """
826
+ Format the cycle inventory for logging as a table. Rows represent inventory years, columns represent the share of
827
+ emission for each cycle present in the inventory. If the inventory is invalid, return `"None"` as a string.
828
+ """
829
+ KEY = _InventoryKey.SHARE_OF_EMISSION
830
+
831
+ unique_cycles = sorted(
832
+ set(non_empty_list(flatten(list(group[KEY]) for group in cycle_inventory.values()))),
833
+ key=lambda id: next((year, id) for year in cycle_inventory if id in cycle_inventory[year][KEY])
834
+ )
835
+
836
+ should_run = cycle_inventory and len(unique_cycles) > 0
837
+
838
+ return log_as_table(
839
+ {
840
+ "year": year,
841
+ **{
842
+ id: _format_number(group.get(KEY, {}).get(id, 0)) for id in unique_cycles
843
+ }
844
+ } for year, group in cycle_inventory.items()
845
+ ) if should_run else "None"
846
+
847
+
848
+ def _format_carbon_stock_inventory(carbon_stock_inventory: dict) -> str:
849
+ """
850
+ Format the carbon stock inventory for logging as a table. Rows represent inventory years, columns represent carbon
851
+ stock change data for each measurement method classification present in inventory. If the inventory is invalid,
852
+ return `"None"` as a string.
853
+ """
854
+ KEYS = [
855
+ _InventoryKey.CARBON_STOCK,
856
+ _InventoryKey.CARBON_STOCK_CHANGE,
857
+ _InventoryKey.CO2_EMISSION
858
+ ]
859
+
860
+ methods = carbon_stock_inventory.keys()
861
+ method_columns = list(product(methods, KEYS))
862
+ inventory_years = sorted(set(non_empty_list(flatten(list(years) for years in carbon_stock_inventory.values()))))
863
+
864
+ should_run = carbon_stock_inventory and len(inventory_years) > 0
865
+
866
+ return log_as_table(
867
+ {
868
+ "year": year,
869
+ **{
870
+ _format_column_header(method, key): _format_named_tuple(
871
+ carbon_stock_inventory.get(method, {}).get(year, {}).get(key, {})
872
+ ) for method, key in method_columns
873
+ }
874
+ } for year in inventory_years
875
+ ) if should_run else "None"
876
+
877
+
878
+ def _format_number(value: Optional[float]) -> str:
879
+ """Format a float for logging in a table. If the value is invalid, return `"None"` as a string."""
880
+ return f"{value:.1f}" if isinstance(value, (float, int)) else "None"
881
+
882
+
883
+ def _format_column_header(method: MeasurementMethodClassification, inventory_key: _InventoryKey) -> str:
884
+ """
885
+ Format a measurement method classification and inventory key for logging in a table as a column header. Replace any
886
+ whitespaces in the method value with dashes and concatenate it with the inventory key value, which already has the
887
+ correct format.
888
+ """
889
+ return "-".join([
890
+ method.value.replace(" ", "-"),
891
+ inventory_key.value
892
+ ])
893
+
894
+
895
+ def _format_named_tuple(value: Optional[Union[CarbonStock, CarbonStockChange, CarbonStockChangeEmission]]) -> str:
896
+ """
897
+ Format a named tuple (`CarbonStock`, `CarbonStockChange` or `CarbonStockChangeEmission`) for logging in a table.
898
+ Extract and format just the value and discard the other data. If the value is invalid, return `"None"` as a string.
899
+ """
900
+ return (
901
+ _format_number(mean(value.value))
902
+ if isinstance(value, (CarbonStock, CarbonStockChange, CarbonStockChangeEmission))
903
+ else "None"
904
+ )