hestia-earth-models 0.57.2__py3-none-any.whl → 0.59.0__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 (109) hide show
  1. hestia_earth/models/cycle/aboveGroundCropResidueTotal.py +17 -12
  2. hestia_earth/models/cycle/excretaKgMass.py +4 -5
  3. hestia_earth/models/cycle/excretaKgN.py +4 -5
  4. hestia_earth/models/cycle/excretaKgVs.py +4 -5
  5. hestia_earth/models/cycle/inorganicFertiliser.py +2 -2
  6. hestia_earth/models/cycle/{irrigated.py → irrigatedTypeUnspecified.py} +4 -4
  7. hestia_earth/models/cycle/liveAnimal.py +9 -11
  8. hestia_earth/models/cycle/milkYield.py +154 -0
  9. hestia_earth/models/cycle/residueIncorporated.py +1 -1
  10. hestia_earth/models/cycle/utils.py +6 -0
  11. hestia_earth/models/emepEea2019/nh3ToAirInorganicFertiliser.py +3 -3
  12. hestia_earth/models/faostat2018/seed.py +2 -3
  13. hestia_earth/models/geospatialDatabase/clayContent.py +17 -4
  14. hestia_earth/models/geospatialDatabase/sandContent.py +17 -4
  15. hestia_earth/models/geospatialDatabase/siltContent.py +2 -2
  16. hestia_earth/models/impact_assessment/irrigated.py +0 -3
  17. hestia_earth/models/ipcc2006/co2ToAirOrganicSoilCultivation.py +2 -2
  18. hestia_earth/models/ipcc2006/n2OToAirCropResidueDecompositionIndirect.py +2 -2
  19. hestia_earth/models/ipcc2006/n2OToAirExcretaDirect.py +1 -1
  20. hestia_earth/models/ipcc2006/n2OToAirExcretaIndirect.py +8 -4
  21. hestia_earth/models/ipcc2006/n2OToAirInorganicFertiliserDirect.py +4 -1
  22. hestia_earth/models/ipcc2006/n2OToAirInorganicFertiliserIndirect.py +1 -1
  23. hestia_earth/models/ipcc2006/n2OToAirOrganicFertiliserDirect.py +1 -1
  24. hestia_earth/models/ipcc2006/n2OToAirOrganicFertiliserIndirect.py +1 -1
  25. hestia_earth/models/ipcc2006/utils.py +11 -8
  26. hestia_earth/models/ipcc2019/ch4ToAirEntericFermentation.py +4 -4
  27. hestia_earth/models/ipcc2019/ch4ToAirFloodedRice.py +16 -7
  28. hestia_earth/models/ipcc2019/co2ToAirSoilCarbonStockChangeManagementChange.py +759 -0
  29. hestia_earth/models/ipcc2019/croppingDuration.py +12 -6
  30. hestia_earth/models/ipcc2019/n2OToAirCropResidueDecompositionDirect.py +5 -52
  31. hestia_earth/models/ipcc2019/n2OToAirInorganicFertiliserDirect.py +104 -0
  32. hestia_earth/models/ipcc2019/n2OToAirInorganicFertiliserIndirect.py +1 -1
  33. hestia_earth/models/ipcc2019/n2OToAirOrganicFertiliserDirect.py +105 -0
  34. hestia_earth/models/ipcc2019/n2OToAirOrganicFertiliserIndirect.py +1 -1
  35. hestia_earth/models/ipcc2019/no3ToGroundwaterCropResidueDecomposition.py +1 -1
  36. hestia_earth/models/ipcc2019/no3ToGroundwaterExcreta.py +1 -1
  37. hestia_earth/models/ipcc2019/no3ToGroundwaterInorganicFertiliser.py +1 -1
  38. hestia_earth/models/ipcc2019/no3ToGroundwaterOrganicFertiliser.py +1 -1
  39. hestia_earth/models/ipcc2019/organicCarbonPerHa.py +1088 -1268
  40. hestia_earth/models/ipcc2019/pastureGrass.py +4 -4
  41. hestia_earth/models/ipcc2019/utils.py +102 -1
  42. hestia_earth/models/koble2014/aboveGroundCropResidue.py +15 -17
  43. hestia_earth/models/koble2014/cropResidueManagement.py +2 -2
  44. hestia_earth/models/koble2014/utils.py +19 -3
  45. hestia_earth/models/linkedImpactAssessment/__init__.py +4 -2
  46. hestia_earth/models/log.py +15 -3
  47. hestia_earth/models/mocking/search-results.json +184 -118
  48. hestia_earth/models/pooreNemecek2018/excretaKgN.py +6 -7
  49. hestia_earth/models/pooreNemecek2018/excretaKgVs.py +7 -6
  50. hestia_earth/models/pooreNemecek2018/no3ToGroundwaterCropResidueDecomposition.py +3 -2
  51. hestia_earth/models/pooreNemecek2018/no3ToGroundwaterExcreta.py +3 -2
  52. hestia_earth/models/pooreNemecek2018/no3ToGroundwaterInorganicFertiliser.py +3 -2
  53. hestia_earth/models/pooreNemecek2018/saplings.py +0 -1
  54. hestia_earth/models/site/management.py +168 -0
  55. hestia_earth/models/site/organicCarbonPerHa.py +251 -89
  56. hestia_earth/models/stehfestBouwman2006/n2OToAirCropResidueDecompositionDirect.py +3 -2
  57. hestia_earth/models/stehfestBouwman2006/n2OToAirExcretaDirect.py +3 -2
  58. hestia_earth/models/stehfestBouwman2006/n2OToAirInorganicFertiliserDirect.py +3 -2
  59. hestia_earth/models/stehfestBouwman2006/n2OToAirOrganicFertiliserDirect.py +3 -2
  60. hestia_earth/models/stehfestBouwman2006/noxToAirCropResidueDecomposition.py +3 -2
  61. hestia_earth/models/stehfestBouwman2006/noxToAirExcreta.py +3 -2
  62. hestia_earth/models/stehfestBouwman2006/noxToAirInorganicFertiliser.py +3 -2
  63. hestia_earth/models/stehfestBouwman2006/noxToAirOrganicFertiliser.py +3 -2
  64. hestia_earth/models/stehfestBouwman2006GisImplementation/noxToAirCropResidueDecomposition.py +3 -2
  65. hestia_earth/models/stehfestBouwman2006GisImplementation/noxToAirExcreta.py +3 -2
  66. hestia_earth/models/stehfestBouwman2006GisImplementation/noxToAirInorganicFertiliser.py +3 -2
  67. hestia_earth/models/stehfestBouwman2006GisImplementation/noxToAirOrganicFertiliser.py +3 -2
  68. hestia_earth/models/utils/aggregated.py +1 -0
  69. hestia_earth/models/utils/blank_node.py +394 -72
  70. hestia_earth/models/utils/cropResidue.py +13 -0
  71. hestia_earth/models/utils/cycle.py +18 -9
  72. hestia_earth/models/utils/measurement.py +1 -1
  73. hestia_earth/models/utils/property.py +4 -4
  74. hestia_earth/models/utils/term.py +48 -3
  75. hestia_earth/models/version.py +1 -1
  76. {hestia_earth_models-0.57.2.dist-info → hestia_earth_models-0.59.0.dist-info}/METADATA +5 -9
  77. {hestia_earth_models-0.57.2.dist-info → hestia_earth_models-0.59.0.dist-info}/RECORD +109 -97
  78. {hestia_earth_models-0.57.2.dist-info → hestia_earth_models-0.59.0.dist-info}/WHEEL +1 -1
  79. tests/models/cycle/animal/input/test_hestiaAggregatedData.py +2 -14
  80. tests/models/cycle/input/test_hestiaAggregatedData.py +4 -16
  81. tests/models/cycle/test_coldCarcassWeightPerHead.py +1 -1
  82. tests/models/cycle/test_coldDressedCarcassWeightPerHead.py +1 -1
  83. tests/models/cycle/{test_irrigated.py → test_irrigatedTypeUnspecified.py} +1 -1
  84. tests/models/cycle/test_milkYield.py +58 -0
  85. tests/models/cycle/test_readyToCookWeightPerHead.py +1 -1
  86. tests/models/emepEea2019/test_nh3ToAirInorganicFertiliser.py +1 -1
  87. tests/models/geospatialDatabase/test_clayContent.py +9 -3
  88. tests/models/geospatialDatabase/test_sandContent.py +9 -3
  89. tests/models/ipcc2006/test_n2OToAirExcretaDirect.py +7 -2
  90. tests/models/ipcc2006/test_n2OToAirExcretaIndirect.py +1 -1
  91. tests/models/ipcc2006/test_n2OToAirInorganicFertiliserDirect.py +7 -2
  92. tests/models/ipcc2006/test_n2OToAirInorganicFertiliserIndirect.py +7 -2
  93. tests/models/ipcc2006/test_n2OToAirOrganicFertiliserDirect.py +7 -2
  94. tests/models/ipcc2006/test_n2OToAirOrganicFertiliserIndirect.py +7 -2
  95. tests/models/ipcc2019/test_ch4ToAirEntericFermentation.py +1 -1
  96. tests/models/ipcc2019/test_co2ToAirSoilCarbonStockChangeManagementChange.py +228 -0
  97. tests/models/ipcc2019/test_n2OToAirInorganicFertiliserDirect.py +74 -0
  98. tests/models/ipcc2019/test_n2OToAirOrganicFertiliserDirect.py +74 -0
  99. tests/models/ipcc2019/test_organicCarbonPerHa.py +303 -1044
  100. tests/models/koble2014/test_residueBurnt.py +1 -2
  101. tests/models/koble2014/test_residueLeftOnField.py +1 -2
  102. tests/models/koble2014/test_residueRemoved.py +1 -2
  103. tests/models/koble2014/test_utils.py +52 -0
  104. tests/models/site/test_management.py +117 -0
  105. tests/models/site/test_organicCarbonPerHa.py +51 -5
  106. tests/models/utils/test_blank_node.py +230 -34
  107. tests/models/utils/test_term.py +17 -3
  108. {hestia_earth_models-0.57.2.dist-info → hestia_earth_models-0.59.0.dist-info}/LICENSE +0 -0
  109. {hestia_earth_models-0.57.2.dist-info → hestia_earth_models-0.59.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,759 @@
1
+ from collections.abc import Iterable
2
+ from enum import Enum
3
+ from functools import reduce
4
+ from pydash.objects import merge
5
+ from typing import NamedTuple, Optional, Union
6
+
7
+ from hestia_earth.schema import (
8
+ CycleFunctionalUnit, EmissionMethodTier, MeasurementMethodClassification
9
+ )
10
+ from hestia_earth.utils.date import diff_in_days
11
+ from hestia_earth.utils.tools import flatten, non_empty_list
12
+
13
+ from hestia_earth.models.log import log_as_table, logRequirements, logShouldRun
14
+ from hestia_earth.models.utils.blank_node import (
15
+ group_nodes_by_year, GroupNodesByYearMode, node_term_match,
16
+ )
17
+ from hestia_earth.models.utils.constant import Units, get_atomic_conversion
18
+ from hestia_earth.models.utils.emission import _new_emission
19
+ from hestia_earth.models.utils.measurement import OLDEST_DATE
20
+ from hestia_earth.models.utils.site import related_cycles
21
+
22
+ from .utils import check_consecutive
23
+ from . import MODEL
24
+
25
+ REQUIREMENTS = {
26
+ "Cycle": {
27
+ "site": {
28
+ "measurements": [
29
+ {
30
+ "@type": "Measurement",
31
+ "value": "",
32
+ "dates": "",
33
+ "depthUpper": "0",
34
+ "depthLower": "30",
35
+ "term.@id": " organicCarbonPerHa"
36
+ }
37
+ ]
38
+ },
39
+ "functionalUnit": "1 ha",
40
+ "endDate": "",
41
+ "optional": {
42
+ "startDate": ""
43
+ }
44
+ }
45
+ }
46
+ RETURNS = {
47
+ "Emission": [{
48
+ "value": "",
49
+ "methodTier": "",
50
+ "depth": "30"
51
+ }]
52
+ }
53
+ TERM_ID = 'co2ToAirSoilCarbonStockChangeManagementChange'
54
+
55
+ DEPTH_UPPER = 0
56
+ DEPTH_LOWER = 30
57
+
58
+ ORGANIC_CARBON_PER_HA_TERM_ID = 'organicCarbonPerHa'
59
+
60
+
61
+ SocStock = NamedTuple("SocStock", [
62
+ ("value", float),
63
+ ("method", MeasurementMethodClassification)
64
+ ])
65
+ """
66
+ NamedTuple representing either an SOC stock or SOC stock change.
67
+
68
+ Attributes
69
+ ----------
70
+ value : float
71
+ The value of the SOC stock (kg C ha-1).
72
+ method: MeasurementMethodClassification
73
+ The measurement method for the SOC stock.
74
+ """
75
+
76
+
77
+ _InnerKey = Enum("_InnerKey", [
78
+ "SOC_STOCK",
79
+ "SOC_STOCK_CHANGE",
80
+ "SHARE_OF_EMISSIONS"
81
+ ])
82
+ """
83
+ The inner keys of the annualised inventory created by the `_should_run` function.
84
+ """
85
+
86
+
87
+ REQUIRED_INNER_KEYS = [_InnerKey.SHARE_OF_EMISSIONS, _InnerKey.SOC_STOCK_CHANGE]
88
+
89
+ MEASUREMENT_METHOD_RANKING = [
90
+ MeasurementMethodClassification.UNSOURCED_ASSUMPTION,
91
+ MeasurementMethodClassification.EXPERT_OPINION,
92
+ MeasurementMethodClassification.COUNTRY_LEVEL_STATISTICAL_DATA,
93
+ MeasurementMethodClassification.REGIONAL_STATISTICAL_DATA,
94
+ MeasurementMethodClassification.GEOSPATIAL_DATASET,
95
+ MeasurementMethodClassification.PHYSICAL_MEASUREMENT_ON_NEARBY_SITE,
96
+ MeasurementMethodClassification.TIER_1_MODEL,
97
+ MeasurementMethodClassification.TIER_2_MODEL,
98
+ MeasurementMethodClassification.TIER_3_MODEL,
99
+ MeasurementMethodClassification.MODELLED_USING_OTHER_MEASUREMENTS,
100
+ MeasurementMethodClassification.ON_SITE_PHYSICAL_MEASUREMENT
101
+ ]
102
+ """
103
+ A ranking of `MeasurementMethodClassification`s from weakest to strongest used to determine the `EmissionMethodTier` of
104
+ the `co2ToAirSoilCarbonStockChangeManagementChange` output.
105
+
106
+ The `EmissionMethodTier` should be based on the weakest `MeasurementMethodClassification` between the current SOC and
107
+ previous SOC.
108
+ """
109
+
110
+
111
+ def _to_measurement_method_classification(
112
+ method: Union[str, MeasurementMethodClassification]
113
+ ) -> Optional[MeasurementMethodClassification]:
114
+ """
115
+ Convert the input to a MeasurementMethodClassification object if possible.
116
+
117
+ Parameters
118
+ ----------
119
+ method : str | MeasurementMethodClassification
120
+ The measurement method as either a `str` or `MeasurementMethodClassification`.
121
+
122
+ Returns
123
+ -------
124
+ MeasurementMethodClassification | None
125
+ The matching `MeasurementMethodClassification` or `None` if invalid string.
126
+ """
127
+ return (
128
+ method if isinstance(method, MeasurementMethodClassification)
129
+ else MeasurementMethodClassification(method) if method in (m.value for m in MeasurementMethodClassification)
130
+ else None
131
+ )
132
+
133
+
134
+ def _get_min_measurement_method(
135
+ *methods: Union[MeasurementMethodClassification, Iterable[MeasurementMethodClassification]]
136
+ ) -> MeasurementMethodClassification:
137
+ """
138
+ Get the minimum ranking measurement method from the provided methods.
139
+
140
+ Parameters
141
+ ----------
142
+ *methods : MeasurementMethodClassification | Iterable[MeasurementMethodClassification]
143
+ Measurement methods or iterables of measurement methods.
144
+
145
+ Returns
146
+ -------
147
+ MeasurementMethodClassification
148
+ The measurement method with the minimum ranking.
149
+ """
150
+
151
+ # flatten methods into a single list, convert any strings into `MeasurementMethodClassification`s
152
+ # and remove invalid methods.
153
+ _methods = non_empty_list(flatten([
154
+ [_to_measurement_method_classification(method) for method in arg] if isinstance(arg, Iterable)
155
+ else [_to_measurement_method_classification(arg)] for arg in methods
156
+ ]))
157
+
158
+ return min(
159
+ _methods,
160
+ key=lambda method: MEASUREMENT_METHOD_RANKING.index(method),
161
+ default=list(MEASUREMENT_METHOD_RANKING)[0]
162
+ )
163
+
164
+
165
+ def _get_max_measurement_method(
166
+ *methods: Union[MeasurementMethodClassification, Iterable[MeasurementMethodClassification]]
167
+ ) -> MeasurementMethodClassification:
168
+ """
169
+ Get the max ranking measurement method from the provided methods.
170
+
171
+ Parameters
172
+ ----------
173
+ *methods : MeasurementMethodClassification | Iterable[MeasurementMethodClassification]
174
+ Measurement methods or iterables of measurement methods.
175
+
176
+ Returns
177
+ -------
178
+ MeasurementMethodClassification
179
+ The measurement method with the maximum ranking.
180
+ """
181
+
182
+ # flatten methods into a single list, convert any strings into `MeasurementMethodClassification`s
183
+ # and remove invalid methods.
184
+ _methods = non_empty_list(flatten([
185
+ [_to_measurement_method_classification(method) for method in arg] if isinstance(arg, Iterable)
186
+ else [_to_measurement_method_classification(arg)] for arg in methods
187
+ ]))
188
+
189
+ return max(
190
+ _methods,
191
+ key=lambda method: MEASUREMENT_METHOD_RANKING.index(method),
192
+ default=MEASUREMENT_METHOD_RANKING[-1]
193
+ )
194
+
195
+
196
+ DEFAULT_EMISSION_METHOD_TIER = EmissionMethodTier.TIER_1
197
+ MEASUREMENT_METHOD_CLASSIFICATION_TO_EMISSION_METHOD_TIER = {
198
+ MeasurementMethodClassification.TIER_2_MODEL: EmissionMethodTier.TIER_2,
199
+ MeasurementMethodClassification.TIER_3_MODEL: EmissionMethodTier.TIER_3,
200
+ MeasurementMethodClassification.MODELLED_USING_OTHER_MEASUREMENTS: EmissionMethodTier.MEASURED,
201
+ MeasurementMethodClassification.ON_SITE_PHYSICAL_MEASUREMENT: EmissionMethodTier.MEASURED,
202
+ }
203
+ """
204
+ A mapping between `MeasurementMethodClassification`s and `EmissionMethodTier`s. As SOC measurements can be
205
+ measured/estimated through a variety of methods, the emission model needs be able to assign an emission tier for each.
206
+ Any `MeasurementMethodClassification` not in the mapping should be assigned `DEFAULT_EMISSION_METHOD_TIER`.
207
+ """
208
+
209
+
210
+ def _get_emission_method_tier(
211
+ measurement_method: MeasurementMethodClassification
212
+ ) -> EmissionMethodTier:
213
+ """
214
+ Get the emission method tier based on the provided measurement method.
215
+
216
+ Parameters
217
+ ----------
218
+ measurement_method : MeasurementMethodClassification
219
+ The measurement method classification.
220
+
221
+ Returns
222
+ -------
223
+ EmissionMethodTier
224
+ The corresponding emission method tier.
225
+ """
226
+ return MEASUREMENT_METHOD_CLASSIFICATION_TO_EMISSION_METHOD_TIER.get(
227
+ measurement_method, DEFAULT_EMISSION_METHOD_TIER
228
+ )
229
+
230
+
231
+ def _emission(
232
+ value: float, method_tier: EmissionMethodTier
233
+ ) -> dict:
234
+ """
235
+ Create an emission node based on the provided value and method tier.
236
+
237
+ See [Emission schema](https://www.hestia.earth/schema/Emission) for more information.
238
+
239
+ Parameters
240
+ ----------
241
+ value : float
242
+ The emission value (kg CO2 ha-1).
243
+
244
+ method_tier : EmissionMethodTier
245
+ The emission method tier.
246
+
247
+ Returns
248
+ -------
249
+ dict
250
+ The emission dictionary with keys 'depth', 'value', and 'methodTier'.
251
+ """
252
+ emission = _new_emission(TERM_ID, MODEL)
253
+ emission["depth"] = DEPTH_LOWER
254
+ emission["value"] = [value]
255
+ emission["methodTier"] = method_tier.value
256
+ return emission
257
+
258
+
259
+ def _linear_interpolate_soc_stock(
260
+ start_year: int,
261
+ end_year: int,
262
+ start_soc_stock: SocStock,
263
+ end_soc_stock: SocStock,
264
+ year: int
265
+ ) -> SocStock:
266
+ """
267
+ Linearly interpolate the SocStock value for a specific year between two given years.
268
+
269
+ The `MeasurementMethodClassification` of any SOC stocks estimated using this method should be `tier 1 model` as the
270
+ method is derived from IPCC (2019) Tier 1 SOC model.
271
+
272
+ Parameters
273
+ ----------
274
+ start_year : int
275
+ The start year for interpolation.
276
+ end_year : int
277
+ The end year for interpolation.
278
+ start_soc_stock : SocStock
279
+ The `SocStock` corresponding to the start year.
280
+ end_soc_stock : SocStock
281
+ The `SocStock` corresponding to the end year.
282
+ year : int
283
+ The target year for interpolation.
284
+
285
+ Returns
286
+ -------
287
+ SocStock
288
+ The interpolated `SocStock` for the specified year.
289
+ """
290
+ METHOD = MeasurementMethodClassification.TIER_1_MODEL
291
+
292
+ time_ratio = (year - start_year) / (end_year - start_year)
293
+ soc_delta = (end_soc_stock.value - start_soc_stock.value) * time_ratio
294
+ value = start_soc_stock.value + soc_delta
295
+
296
+ return SocStock(value, METHOD)
297
+
298
+
299
+ def _calc_soc_stock_change(start_soc_stock: SocStock, end_soc_stock: SocStock) -> SocStock:
300
+ """
301
+ Calculate the change in SOC stock change between the current and previous states.
302
+
303
+ The method should be the weaker of the two `MeasurementMethodClassification`s.
304
+
305
+ Parameters
306
+ ----------
307
+ start_soc_stock : SocStock
308
+ The SOC stock at the start (kg C ha-1).
309
+
310
+ end_soc_stock : SocStock
311
+ The SOC stock at the end (kg C ha-1).
312
+
313
+ Returns
314
+ -------
315
+ SocStock
316
+ The SOC stock change (kg C ha-1).
317
+ """
318
+ value = end_soc_stock.value - start_soc_stock.value
319
+ method = _get_min_measurement_method(end_soc_stock.method, start_soc_stock.method)
320
+
321
+ return SocStock(value, method)
322
+
323
+
324
+ def _convert_c_to_co2(kg_c: float) -> float:
325
+ """
326
+ Convert mass of carbon (C) to carbon dioxide (CO2) using the atomic conversion ratio.
327
+
328
+ n.b. `get_atomic_conversion` returns the ratio C:CO2 (~44/12).
329
+
330
+ Parameters
331
+ ----------
332
+ kg_c : float
333
+ Mass of carbon (C) to be converted to carbon dioxide (CO2) (kg C).
334
+
335
+ Returns
336
+ -------
337
+ float
338
+ Mass of carbon dioxide (CO2) resulting from the conversion (kg CO2).
339
+ """
340
+ return kg_c * get_atomic_conversion(Units.KG_CO2, Units.TO_C)
341
+
342
+
343
+ def _soc_stock_stock_change_to_co2_emission(
344
+ soc_stock_change_value: float,
345
+ share_of_emission: float
346
+ ) -> float:
347
+ """
348
+ Convert SOC stock change to CO2 emission using the given share of emission.
349
+
350
+ Parameters
351
+ ----------
352
+ soc_stock_change_value : float
353
+ The change in SOC stock value.
354
+
355
+ share_of_emission : float
356
+ The share of emission associated with the SOC stock change.
357
+
358
+ Returns
359
+ -------
360
+ float
361
+ The corresponding CO2 emission resulting from the SOC stock change.
362
+ """
363
+ return -1 * share_of_emission * _convert_c_to_co2(soc_stock_change_value)
364
+
365
+
366
+ def _sorted_merge(*sources: Union[dict, list[dict]]) -> dict:
367
+ """
368
+ Merge dictionaries and return the result as a new dictionary with keys sorted in order to preserve the temporal
369
+ order of inventory years.
370
+
371
+ Parameters
372
+ ----------
373
+ *sources : dict | List[dict]
374
+ One or more dictionaries or lists of dictionaries to be merged.
375
+
376
+ Returns
377
+ -------
378
+ dict
379
+ A new dictionary containing the merged key-value pairs, with keys sorted.
380
+ """
381
+
382
+ _sources = non_empty_list(
383
+ flatten([arg if isinstance(arg, list) else [arg] for arg in sources])
384
+ )
385
+
386
+ merged = reduce(merge, _sources, {})
387
+ return dict(sorted(merged.items()))
388
+
389
+
390
+ def _validate_soc_measurement_node(node: dict) -> bool:
391
+ """
392
+ Validate a SOC measurement node against specified criteria.
393
+
394
+ Parameters
395
+ ----------
396
+ node : dict
397
+ The SOC [Measurement node](https://www.hestia.earth/schema/Measurement) to be validated.
398
+
399
+ Returns
400
+ -------
401
+ bool
402
+ True if the node passes all validation criteria, False otherwise.
403
+ """
404
+ return all([
405
+ node_term_match(node, ORGANIC_CARBON_PER_HA_TERM_ID),
406
+ node.get("depthUpper") == DEPTH_UPPER,
407
+ node.get("depthLower") == DEPTH_LOWER
408
+ ])
409
+
410
+
411
+ def _nodes_to_soc_stock(year: int, nodes: list[dict]) -> SocStock:
412
+ """
413
+ Reduces all the the SOC measurement nodes in an inventory year into a single value and measurement method.
414
+
415
+ Any nodes with missing or invalid `dates` field will already have been filtered out at this point, so we can assume
416
+ `node.value` and `node.dates` will have equal number of elements. See test case `missing-measurement-dates`.
417
+
418
+ Parameters
419
+ ----------
420
+ year : int
421
+ The target year for calculating the SOC stock.
422
+
423
+ nodes : List[dict]
424
+ List of [Measurement nodes](https://www.hestia.earth/schema/Measurement) containing SOC data.
425
+
426
+ Returns
427
+ -------
428
+ SocStock
429
+ The calculated SOC stock for the specified year.
430
+ """
431
+ target_date = f"{year}-12-31T23:59:59"
432
+
433
+ values = flatten([measurement.get("value", []) for measurement in nodes])
434
+ dates = flatten([measurement.get("dates", []) for measurement in nodes])
435
+ methods = flatten([
436
+ [measurement.get("methodClassification") for _ in measurement.get("value", [])]
437
+ for measurement in nodes
438
+ ])
439
+
440
+ closest_date = min(
441
+ dates,
442
+ key=lambda date: abs(diff_in_days(date if date else OLDEST_DATE, target_date)),
443
+ )
444
+
445
+ closest_method = _get_max_measurement_method(
446
+ method for method, date in zip(methods, dates) if date == closest_date
447
+ )
448
+
449
+ value = next(
450
+ (value for value, method in zip(values, methods) if method == closest_method.value), 0
451
+ )
452
+
453
+ return SocStock(value, closest_method)
454
+
455
+
456
+ def _group_soc_stocks(site: dict) -> dict:
457
+ """
458
+ Group valid `organicCarbonPerHa` measurement nodes by year (based on node "dates" field) and reduce them to a
459
+ single `SocStock` for each year.
460
+
461
+ Parameters
462
+ ----------
463
+ site : dict
464
+ A [Site node](https://www.hestia.earth/schema/Cycle).
465
+
466
+ Returns
467
+ -------
468
+ dict
469
+ A dictionary where each key represents a year and its corresponding value is a dictionary containing SOC stock
470
+ information under the inner key specified by _InnerKey.SOC_STOCK.
471
+ """
472
+ INNER_KEY = _InnerKey.SOC_STOCK
473
+
474
+ grouped_soc_measurements = group_nodes_by_year(
475
+ (node for node in site.get("measurements", []) if _validate_soc_measurement_node(node)),
476
+ mode=GroupNodesByYearMode.DATES
477
+ )
478
+
479
+ return {
480
+ year: {
481
+ INNER_KEY: (
482
+ _nodes_to_soc_stock(year, nodes)
483
+ )
484
+ } for year, nodes in grouped_soc_measurements.items()
485
+ }
486
+
487
+
488
+ def _interpolate_grouped_soc_stocks(grouped_soc_stocks: dict) -> dict:
489
+ """
490
+ Interpolate SOC stocks for years between grouped SOC stock data.
491
+
492
+ This function iterates over the provided grouped SOC stock data and performs linear interpolation for years between
493
+ existing data points. The result is a dictionary with SOC stock information for all years, including those without
494
+ initially available data.
495
+
496
+ Parameters
497
+ ----------
498
+ grouped_soc_stocks : dict
499
+ Dictionary containing grouped SOC stock data with years as keys.
500
+
501
+ Returns
502
+ -------
503
+ dict
504
+ A dictionary with interpolated SOC stock data for missing years.
505
+ """
506
+
507
+ INNER_KEY = _InnerKey.SOC_STOCK
508
+
509
+ def group_interpolate(data: dict, i: int):
510
+ current_year = list(grouped_soc_stocks.keys())[i]
511
+ prev_year = list(grouped_soc_stocks.keys())[i-1]
512
+
513
+ current_soc_stock = grouped_soc_stocks[current_year][INNER_KEY]
514
+ prev_soc_stock = grouped_soc_stocks[prev_year][INNER_KEY]
515
+
516
+ return data | {
517
+ inner_year: {
518
+ INNER_KEY: _linear_interpolate_soc_stock(
519
+ prev_year, current_year, prev_soc_stock, current_soc_stock, inner_year
520
+ )
521
+ } for inner_year in range(prev_year+1, current_year)
522
+ }
523
+
524
+ return reduce(group_interpolate, range(1, len(grouped_soc_stocks)), dict())
525
+
526
+
527
+ def _get_grouped_soc_stocks(site: dict) -> dict:
528
+ """
529
+ Get grouped and interpolated SOC stocks for a site.
530
+
531
+ This function combines grouping and interpolation of SOC stocks for a given site, providing a comprehensive
532
+ dictionary with SOC stock information for all years.
533
+
534
+ Parameters
535
+ ----------
536
+ site : dict
537
+ The site dictionary containing SOC measurements.
538
+
539
+ Returns
540
+ -------
541
+ dict
542
+ A dictionary with grouped and interpolated SOC stock data for all years.
543
+ """
544
+ grouped_soc_stocks = _group_soc_stocks(site)
545
+ grouped_interpolated_soc_stocks = _interpolate_grouped_soc_stocks(grouped_soc_stocks)
546
+ return _sorted_merge(grouped_soc_stocks, grouped_interpolated_soc_stocks)
547
+
548
+
549
+ def _calc_grouped_soc_stock_changes(grouped_soc_stocks: dict) -> dict:
550
+ """
551
+ Calculate SOC stock changes between grouped SOC stock data for consecutive years.
552
+
553
+ Parameters
554
+ ----------
555
+ grouped_soc_stocks : dict
556
+ Dictionary containing grouped SOC stock data with years as keys.
557
+
558
+ Returns
559
+ -------
560
+ dict
561
+ A dictionary with calculated SOC stock changes for consecutive years.
562
+ """
563
+ INNER_KEY = _InnerKey.SOC_STOCK_CHANGE
564
+
565
+ def group_changes(data: dict, i: int):
566
+ current_year = list(grouped_soc_stocks.keys())[i]
567
+ prev_year = list(grouped_soc_stocks.keys())[i-1]
568
+
569
+ current_soc_stock = grouped_soc_stocks[current_year][_InnerKey.SOC_STOCK]
570
+ prev_soc_stock = grouped_soc_stocks[prev_year][_InnerKey.SOC_STOCK]
571
+
572
+ return data | {
573
+ current_year: {
574
+ INNER_KEY: _calc_soc_stock_change(prev_soc_stock, current_soc_stock)
575
+ }
576
+ }
577
+
578
+ return reduce(group_changes, range(1, len(grouped_soc_stocks)), dict())
579
+
580
+
581
+ def _calc_sum_cycle_occupancy(cycles: list[dict]) -> float:
582
+ """
583
+ Calculate the sum of cycle occupancies based on the `fraction_of_group_duration` field added by the
584
+ `group_nodes_by_year` function.
585
+
586
+ If a cycle does not have the "fraction_of_group_duration" key, it is treated as zero occupancy for that year.
587
+
588
+ Parameters
589
+ ----------
590
+ cycles : List[dict]
591
+ List of cycles, where each cycle dictionary should contain a "fraction_of_group_duration" key.
592
+
593
+ Returns
594
+ -------
595
+ float
596
+ The sum of cycle occupancies based on the fraction of the year.
597
+ """
598
+ return sum(cycle.get("fraction_of_group_duration", 0) for cycle in cycles)
599
+
600
+
601
+ def _calc_grouped_share_of_emissions(cycles: list[dict]) -> dict:
602
+ """
603
+ Calculate grouped share of emissions for cycles based on the amount they contribute the the overall land management
604
+ of an inventory year.
605
+
606
+ This function groups cycles by year, then calculates the share of emissions for each cycle based on the
607
+ "fraction_of_group_duration" value. The share of emissions is normalized by the sum of cycle occupancies for the
608
+ entire dataset to ensure the values represent a valid share.
609
+
610
+ Parameters
611
+ ----------
612
+ cycles : List[dict]
613
+ List of [Cycle nodes](https://www.hestia.earth/schema/Cycle), where each cycle dictionary should contain a
614
+ "fraction_of_group_duration" key added by the `group_nodes_by_year` function.
615
+
616
+ Returns
617
+ -------
618
+ dict
619
+ A dictionary with grouped share of emissions for each cycle based on the fraction of the year.
620
+ """
621
+ INNER_KEY = _InnerKey.SHARE_OF_EMISSIONS
622
+ grouped_cycles = group_nodes_by_year(cycles)
623
+ return {
624
+ year: {
625
+ INNER_KEY: {
626
+ cycle["@id"]: cycle.get("fraction_of_group_duration", 0) / _calc_sum_cycle_occupancy(cycles)
627
+ for cycle in cycles
628
+ }
629
+ } for year, cycles in grouped_cycles.items()
630
+ }
631
+
632
+
633
+ def _run(cycle_id: str, grouped_data: dict) -> list[dict]:
634
+ """
635
+ Calculate emissions for a specific cycle using grouped SOC stock change and share of emissions data.
636
+
637
+ The emission method tier based on the minimum measurement method tier among the SOC stock change data in the
638
+ grouped data.
639
+
640
+ Parameters
641
+ ----------
642
+ cycle_id : str
643
+ The "@id" field of the [Cycle node](https://www.hestia.earth/schema/Cycle).
644
+
645
+ grouped_data : dict
646
+ A dictionary containing grouped SOC stock change and share of emissions data.
647
+
648
+ Returns
649
+ -------
650
+ list[dict]
651
+ A list containing emission data calculated for the specified cycle.
652
+ """
653
+
654
+ value = sum(
655
+ _soc_stock_stock_change_to_co2_emission(
656
+ group[_InnerKey.SOC_STOCK_CHANGE].value,
657
+ group[_InnerKey.SHARE_OF_EMISSIONS].get(cycle_id, 1)
658
+ ) for group in grouped_data.values()
659
+ )
660
+
661
+ method_tier = _get_emission_method_tier(
662
+ _get_min_measurement_method(
663
+ group[_InnerKey.SOC_STOCK_CHANGE].method for group in grouped_data.values()
664
+ )
665
+ )
666
+
667
+ return [_emission(value, method_tier)]
668
+
669
+
670
+ def get_site(cycle: dict) -> dict:
671
+ return cycle.get("site", {})
672
+
673
+
674
+ def _should_run(cycle: dict) -> tuple:
675
+ """
676
+ Determine if calculations should run for a given cycle based on SOC stock and emissions data.
677
+
678
+ This function assesses whether calculations should run for a given cycle by checking the availability of SOC stock
679
+ data and cycle nodes. It retrieves SOC stock data and for the site linked to the cycle, calculates SOC stock
680
+ changes and share of emissions, and merges the data into a grouped format. The function checks for the presence of
681
+ necessary keys in the grouped data and ensures that the years are consecutive before determining if calculations
682
+ should run.
683
+
684
+ Parameters
685
+ ----------
686
+ cycle : dict
687
+ The cycle dictionary for which the calculations will be evaluated.
688
+
689
+ Returns
690
+ -------
691
+ tuple
692
+ A tuple containing a boolean indicating whether calculations should run,
693
+ the cycle identifier, and grouped SOC stock and emissions data.
694
+ """
695
+ cycle_id = cycle.get("@id")
696
+ site = get_site(cycle)
697
+ cycles = related_cycles(site.get("@id"))
698
+
699
+ grouped_soc_stocks = _get_grouped_soc_stocks(site)
700
+ grouped_soc_stock_changes = _calc_grouped_soc_stock_changes(grouped_soc_stocks)
701
+ grouped_share_of_emissions = _calc_grouped_share_of_emissions(cycles)
702
+
703
+ def _should_run_group(year: int, group: dict) -> bool:
704
+ is_data_complete = all(key in group.keys() for key in REQUIRED_INNER_KEYS)
705
+ is_relevant_for_cycle = cycle_id in group.get(_InnerKey.SHARE_OF_EMISSIONS, {}).keys()
706
+ return is_data_complete and is_relevant_for_cycle
707
+
708
+ inventory = _sorted_merge(grouped_soc_stocks, grouped_soc_stock_changes, grouped_share_of_emissions)
709
+ valid_years = [year for year, group in inventory.items() if _should_run_group(year, group)]
710
+
711
+ num_organic_carbon_per_ha_measurements = len(
712
+ [node for node in site.get('measurements', []) if _validate_soc_measurement_node(node)]
713
+ )
714
+ has_organic_carbon_per_ha_measurements = num_organic_carbon_per_ha_measurements > 1
715
+ has_functional_unit_1_ha = all(
716
+ cycle.get('functionalUnit') == CycleFunctionalUnit._1_HA.value for cycle in cycles
717
+ )
718
+ has_valid_inventory = len(valid_years) > 0 and check_consecutive(valid_years)
719
+
720
+ logRequirements(
721
+ cycle, model=MODEL, term=TERM_ID,
722
+ has_organic_carbon_per_ha_measurements=has_organic_carbon_per_ha_measurements,
723
+ has_functional_unit_1_ha=has_functional_unit_1_ha,
724
+ has_valid_inventory=has_valid_inventory,
725
+ inventory=log_as_table(
726
+ {
727
+ "year": year,
728
+ "should-run": year in valid_years,
729
+ "soc-stock": (
730
+ group.get(_InnerKey.SOC_STOCK).value if group.get(_InnerKey.SOC_STOCK)
731
+ else None
732
+ ),
733
+ "soc-stock-method": (
734
+ group.get(_InnerKey.SOC_STOCK).method.value if group.get(_InnerKey.SOC_STOCK)
735
+ else None
736
+ ),
737
+ "soc-stock-change": (
738
+ group.get(_InnerKey.SOC_STOCK_CHANGE).value if group.get(_InnerKey.SOC_STOCK_CHANGE)
739
+ else None
740
+ ),
741
+ "soc-stock-change-method": (
742
+ group.get(_InnerKey.SOC_STOCK_CHANGE).method.value if group.get(_InnerKey.SOC_STOCK_CHANGE)
743
+ else None
744
+ ),
745
+ "share-of-emission": group.get(_InnerKey.SHARE_OF_EMISSIONS, {}).get(cycle_id, 0)
746
+ } for year, group in inventory.items()
747
+ )
748
+ )
749
+
750
+ should_run = has_functional_unit_1_ha and has_valid_inventory
751
+ logShouldRun(cycle, MODEL, TERM_ID, should_run)
752
+
753
+ return should_run, cycle_id, {year: group for year, group in inventory.items() if year in valid_years}
754
+
755
+
756
+ def run(cycle: dict) -> list[dict]:
757
+ should_run, *args = _should_run(cycle)
758
+
759
+ return _run(*args) if should_run else []