hestia-earth-models 0.61.7__py3-none-any.whl → 0.62.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 (51) hide show
  1. hestia_earth/models/cycle/completeness/electricityFuel.py +60 -0
  2. hestia_earth/models/cycle/product/economicValueShare.py +47 -31
  3. hestia_earth/models/emepEea2019/nh3ToAirInorganicFertiliser.py +44 -59
  4. hestia_earth/models/geospatialDatabase/histosol.py +4 -0
  5. hestia_earth/models/ipcc2006/co2ToAirOrganicSoilCultivation.py +4 -2
  6. hestia_earth/models/ipcc2006/n2OToAirOrganicSoilCultivationDirect.py +1 -1
  7. hestia_earth/models/ipcc2019/aboveGroundCropResidueTotal.py +1 -1
  8. hestia_earth/models/ipcc2019/animal/pastureGrass.py +30 -24
  9. hestia_earth/models/ipcc2019/belowGroundCropResidue.py +1 -1
  10. hestia_earth/models/ipcc2019/ch4ToAirExcreta.py +1 -1
  11. hestia_earth/models/ipcc2019/co2ToAirSoilOrganicCarbonStockChangeManagementChange.py +511 -458
  12. hestia_earth/models/ipcc2019/co2ToAirUreaHydrolysis.py +5 -1
  13. hestia_earth/models/ipcc2019/organicCarbonPerHa.py +116 -3882
  14. hestia_earth/models/ipcc2019/organicCarbonPerHa_tier_1_utils.py +2060 -0
  15. hestia_earth/models/ipcc2019/organicCarbonPerHa_tier_2_utils.py +1630 -0
  16. hestia_earth/models/ipcc2019/organicCarbonPerHa_utils.py +324 -0
  17. hestia_earth/models/ipcc2019/pastureGrass.py +37 -19
  18. hestia_earth/models/ipcc2019/pastureGrass_utils.py +4 -21
  19. hestia_earth/models/mocking/search-results.json +293 -289
  20. hestia_earth/models/site/organicCarbonPerHa.py +58 -44
  21. hestia_earth/models/site/soilMeasurement.py +18 -13
  22. hestia_earth/models/utils/__init__.py +28 -0
  23. hestia_earth/models/utils/array_builders.py +578 -0
  24. hestia_earth/models/utils/blank_node.py +55 -39
  25. hestia_earth/models/utils/descriptive_stats.py +285 -0
  26. hestia_earth/models/utils/emission.py +73 -2
  27. hestia_earth/models/utils/inorganicFertiliser.py +2 -2
  28. hestia_earth/models/utils/measurement.py +118 -4
  29. hestia_earth/models/version.py +1 -1
  30. {hestia_earth_models-0.61.7.dist-info → hestia_earth_models-0.62.0.dist-info}/METADATA +2 -2
  31. {hestia_earth_models-0.61.7.dist-info → hestia_earth_models-0.62.0.dist-info}/RECORD +51 -39
  32. tests/models/cycle/completeness/test_electricityFuel.py +21 -0
  33. tests/models/cycle/product/test_economicValueShare.py +8 -0
  34. tests/models/emepEea2019/test_nh3ToAirInorganicFertiliser.py +2 -2
  35. tests/models/ipcc2019/animal/test_pastureGrass.py +2 -2
  36. tests/models/ipcc2019/test_co2ToAirSoilOrganicCarbonStockChangeManagementChange.py +55 -165
  37. tests/models/ipcc2019/test_organicCarbonPerHa.py +219 -460
  38. tests/models/ipcc2019/test_organicCarbonPerHa_tier_1_utils.py +471 -0
  39. tests/models/ipcc2019/test_organicCarbonPerHa_tier_2_utils.py +208 -0
  40. tests/models/ipcc2019/test_organicCarbonPerHa_utils.py +75 -0
  41. tests/models/ipcc2019/test_pastureGrass.py +0 -16
  42. tests/models/site/test_organicCarbonPerHa.py +3 -12
  43. tests/models/site/test_soilMeasurement.py +3 -18
  44. tests/models/utils/test_array_builders.py +253 -0
  45. tests/models/utils/test_blank_node.py +154 -15
  46. tests/models/utils/test_descriptive_stats.py +134 -0
  47. tests/models/utils/test_emission.py +51 -1
  48. tests/models/utils/test_measurement.py +54 -2
  49. {hestia_earth_models-0.61.7.dist-info → hestia_earth_models-0.62.0.dist-info}/LICENSE +0 -0
  50. {hestia_earth_models-0.61.7.dist-info → hestia_earth_models-0.62.0.dist-info}/WHEEL +0 -0
  51. {hestia_earth_models-0.61.7.dist-info → hestia_earth_models-0.62.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,8 @@
1
1
  from collections.abc import Iterable
2
+ from datetime import datetime
2
3
  from enum import Enum
3
4
  from functools import reduce
5
+ from itertools import product
4
6
  from pydash.objects import merge
5
7
  from typing import NamedTuple, Optional, Union
6
8
 
@@ -8,15 +10,19 @@ from hestia_earth.schema import (
8
10
  CycleFunctionalUnit, EmissionMethodTier, MeasurementMethodClassification
9
11
  )
10
12
  from hestia_earth.utils.date import diff_in_days
11
- from hestia_earth.utils.tools import flatten, non_empty_list
13
+ from hestia_earth.utils.tools import flatten, non_empty_list, safe_parse_date
12
14
 
13
15
  from hestia_earth.models.log import log_as_table, logRequirements, logShouldRun
16
+ from hestia_earth.models.utils import pairwise
14
17
  from hestia_earth.models.utils.blank_node import (
15
- group_nodes_by_year, GroupNodesByYearMode, node_term_match,
18
+ _get_datestr_format, _gapfill_datestr, DatestrGapfillMode, DatestrFormat, group_nodes_by_year, node_term_match
16
19
  )
17
20
  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
21
+ from hestia_earth.models.utils.emission import _new_emission, min_emission_method_tier
22
+ from hestia_earth.models.utils.measurement import (
23
+ group_measurements_by_method_classification, min_measurement_method_classification,
24
+ to_measurement_method_classification
25
+ )
20
26
  from hestia_earth.models.utils.site import related_cycles
21
27
 
22
28
  from .utils import check_consecutive
@@ -57,580 +63,679 @@ DEPTH_LOWER = 30
57
63
 
58
64
  ORGANIC_CARBON_PER_HA_TERM_ID = 'organicCarbonPerHa'
59
65
 
66
+ VALID_DATE_FORMATS = {
67
+ DatestrFormat.YEAR,
68
+ DatestrFormat.YEAR_MONTH,
69
+ DatestrFormat.YEAR_MONTH_DAY,
70
+ DatestrFormat.YEAR_MONTH_DAY_HOUR_MINUTE_SECOND
71
+ }
72
+
73
+ VALID_MEASUREMENT_METHOD_CLASSIFICATIONS = [
74
+ MeasurementMethodClassification.ON_SITE_PHYSICAL_MEASUREMENT,
75
+ MeasurementMethodClassification.MODELLED_USING_OTHER_MEASUREMENTS,
76
+ MeasurementMethodClassification.TIER_3_MODEL,
77
+ MeasurementMethodClassification.TIER_2_MODEL,
78
+ MeasurementMethodClassification.TIER_1_MODEL
79
+ ]
80
+ """
81
+ The list of `MeasurementMethodClassification`s that can be used to calculate SOC stock change emissions, ranked in
82
+ order from strongest to weakest.
83
+ """
84
+
85
+
86
+ class _InventoryKey(Enum):
87
+ """
88
+ The inner keys of the annualised inventory created by the `_compile_inventory` function.
89
+
90
+ The value of each enum member is formatted to be used as a column header in the `log_as_table` function.
91
+ """
92
+ SOC_STOCK = "soc-stock"
93
+ SOC_STOCK_CHANGE = "soc-stock-change"
94
+ CO2_EMISSION = "co2-emission"
95
+ SHARE_OF_EMISSION = "share-of-emissions"
96
+
60
97
 
61
98
  SocStock = NamedTuple("SocStock", [
62
99
  ("value", float),
100
+ ("date", str),
63
101
  ("method", MeasurementMethodClassification)
64
102
  ])
65
103
  """
66
- NamedTuple representing either an SOC stock or SOC stock change.
104
+ NamedTuple representing an SOC stock.
67
105
 
68
106
  Attributes
69
107
  ----------
70
108
  value : float
71
109
  The value of the SOC stock (kg C ha-1).
110
+ date : str
111
+ The date of the measurement as a datestr with format `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or
112
+ `YYYY-MM-DDTHH:mm:ss`.
72
113
  method: MeasurementMethodClassification
73
114
  The measurement method for the SOC stock.
74
115
  """
75
116
 
76
-
77
- _InnerKey = Enum("_InnerKey", [
78
- "SOC_STOCK",
79
- "SOC_STOCK_CHANGE",
80
- "SHARE_OF_EMISSIONS"
117
+ SocStockChange = NamedTuple("SocStockChange", [
118
+ ("value", float),
119
+ ("start_date", str),
120
+ ("end_date", str),
121
+ ("method", MeasurementMethodClassification)
81
122
  ])
82
123
  """
83
- The inner keys of the annualised inventory created by the `_should_run` function.
84
- """
85
-
124
+ NamedTuple representing an SOC stock change.
86
125
 
87
- REQUIRED_INNER_KEYS = [_InnerKey.SHARE_OF_EMISSIONS, _InnerKey.SOC_STOCK_CHANGE]
126
+ Attributes
127
+ ----------
128
+ value : float
129
+ The value of the SOC stock change (kg C ha-1).
130
+ start_date : str
131
+ The start date of the SOC stock change event as a datestr with the format `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or
132
+ `YYYY-MM-DDTHH:mm:ss`.
133
+ end_date : str
134
+ The end date of the SOC stock change event as a datestr with the format `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or
135
+ `YYYY-MM-DDTHH:mm:ss`.
136
+ method: MeasurementMethodClassification
137
+ The measurement method for the SOC stock change.
138
+ """
88
139
 
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
- ]
140
+ SocStockChangeEmission = NamedTuple("SocStockChangeEmission", [
141
+ ("value", float),
142
+ ("start_date", str),
143
+ ("end_date", str),
144
+ ("method", EmissionMethodTier)
145
+ ])
102
146
  """
103
- A ranking of `MeasurementMethodClassification`s from weakest to strongest used to determine the `EmissionMethodTier` of
104
- the `co2ToAirSoilOrganicCarbonStockChangeManagementChange` output.
147
+ NamedTuple representing an SOC stock change emission.
105
148
 
106
- The `EmissionMethodTier` should be based on the weakest `MeasurementMethodClassification` between the current SOC and
107
- previous SOC.
149
+ Attributes
150
+ ----------
151
+ value : float
152
+ The value of the SOC stock change (kg CO2 ha-1).
153
+ start_date : str
154
+ The start date of the SOC stock change emission as a datestr with the format `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or
155
+ `YYYY-MM-DDTHH:mm:ss`.
156
+ end_date : str
157
+ The end date of the SOC stock change emission as a datestr with the format `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or
158
+ `YYYY-MM-DDTHH:mm:ss`.
159
+ method: MeasurementMethodClassification
160
+ The emission method tier.
108
161
  """
109
162
 
110
163
 
111
- def _to_measurement_method_classification(
112
- method: Union[str, MeasurementMethodClassification]
113
- ) -> Optional[MeasurementMethodClassification]:
164
+ def _emission(value: float, method_tier: EmissionMethodTier) -> dict:
114
165
  """
115
- Convert the input to a MeasurementMethodClassification object if possible.
166
+ Create an emission node based on the provided value and method tier.
167
+
168
+ See [Emission schema](https://www.hestia.earth/schema/Emission) for more information.
116
169
 
117
170
  Parameters
118
171
  ----------
119
- method : str | MeasurementMethodClassification
120
- The measurement method as either a `str` or `MeasurementMethodClassification`.
172
+ value : float
173
+ The emission value (kg CO2 ha-1).
174
+
175
+ method_tier : EmissionMethodTier
176
+ The emission method tier.
121
177
 
122
178
  Returns
123
179
  -------
124
- MeasurementMethodClassification | None
125
- The matching `MeasurementMethodClassification` or `None` if invalid string.
180
+ dict
181
+ The emission dictionary with keys 'depth', 'value', and 'methodTier'.
126
182
  """
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
- )
183
+ emission = _new_emission(TERM_ID, MODEL)
184
+ emission["depth"] = DEPTH_LOWER
185
+ emission["value"] = [value]
186
+ emission["methodTier"] = method_tier.value
187
+ return emission
132
188
 
133
189
 
134
- def _get_min_measurement_method(
135
- *methods: Union[MeasurementMethodClassification, Iterable[MeasurementMethodClassification]]
136
- ) -> MeasurementMethodClassification:
190
+ def _should_run(cycle: dict) -> tuple[bool, str, dict]:
137
191
  """
138
- Get the minimum ranking measurement method from the provided methods.
192
+ Determine if calculations should run for a given [Cycle](https://www.hestia.earth/schema/Cycle) based on SOC stock
193
+ and emissions data.
139
194
 
140
195
  Parameters
141
196
  ----------
142
- *methods : MeasurementMethodClassification | Iterable[MeasurementMethodClassification]
143
- Measurement methods or iterables of measurement methods.
197
+ cycle : dict
198
+ The cycle dictionary for which the calculations will be evaluated.
144
199
 
145
200
  Returns
146
201
  -------
147
- MeasurementMethodClassification
148
- The measurement method with the minimum ranking.
202
+ tuple[bool, str, dict]
203
+ `(should_run, cycle_id, inventory)`
149
204
  """
205
+ cycle_id = cycle.get("@id")
206
+ site = _get_site(cycle)
207
+ soc_measurements = [node for node in site.get("measurements", []) if _validate_soc_measurement(node)]
208
+ cycles = related_cycles(site)
150
209
 
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
- )
210
+ has_soc_measurements = len(soc_measurements) > 0
211
+ has_cycles = len(cycles) > 0
212
+ has_functional_unit_1_ha = all(cycle.get('functionalUnit') == CycleFunctionalUnit._1_HA.value for cycle in cycles)
163
213
 
214
+ should_compile_inventory = all([has_cycles, has_functional_unit_1_ha, has_soc_measurements])
164
215
 
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.
216
+ inventory, logs = _compile_inventory(cycle_id, cycles, soc_measurements) if should_compile_inventory else ({}, {})
170
217
 
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
- """
218
+ has_valid_inventory = len(inventory) > 0
219
+ has_consecutive_years = check_consecutive(inventory.keys())
181
220
 
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]
221
+ logRequirements(
222
+ cycle, model=MODEL, term=TERM_ID,
223
+ has_soc_measurements=has_soc_measurements,
224
+ has_cycles=has_cycles,
225
+ has_functional_unit_1_ha=has_functional_unit_1_ha,
226
+ has_valid_inventory=has_valid_inventory,
227
+ has_consecutive_years=has_consecutive_years,
228
+ **logs
193
229
  )
194
230
 
231
+ should_run = all([has_valid_inventory, has_consecutive_years])
195
232
 
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
- """
233
+ logShouldRun(cycle, MODEL, TERM_ID, should_run)
208
234
 
235
+ return should_run, cycle_id, inventory
209
236
 
210
- def _get_emission_method_tier(
211
- measurement_method: MeasurementMethodClassification
212
- ) -> EmissionMethodTier:
237
+
238
+ def _get_site(cycle: dict) -> dict:
213
239
  """
214
- Get the emission method tier based on the provided measurement method.
240
+ Get the [Site](https://www.hestia.earth/schema/Site) data from a [Cycle](https://www.hestia.earth/schema/Cycle).
215
241
 
216
242
  Parameters
217
243
  ----------
218
- measurement_method : MeasurementMethodClassification
219
- The measurement method classification.
244
+ cycle : dict
220
245
 
221
246
  Returns
222
247
  -------
223
- EmissionMethodTier
224
- The corresponding emission method tier.
248
+ str
225
249
  """
226
- return MEASUREMENT_METHOD_CLASSIFICATION_TO_EMISSION_METHOD_TIER.get(
227
- measurement_method, DEFAULT_EMISSION_METHOD_TIER
228
- )
250
+ return cycle.get("site", {})
229
251
 
230
252
 
231
- def _emission(
232
- value: float, method_tier: EmissionMethodTier
233
- ) -> dict:
253
+ def _validate_soc_measurement(node: dict) -> bool:
234
254
  """
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.
255
+ Validate a [Measurement](https://www.hestia.earth/schema/Measurement) to determine whether it is a valid
256
+ `organicCarbonPerHa` node.
238
257
 
239
258
  Parameters
240
259
  ----------
241
- value : float
242
- The emission value (kg CO2 ha-1).
243
-
244
- method_tier : EmissionMethodTier
245
- The emission method tier.
260
+ node : dict
261
+ The node to be validated.
246
262
 
247
263
  Returns
248
264
  -------
249
- dict
250
- The emission dictionary with keys 'depth', 'value', and 'methodTier'.
265
+ bool
266
+ `True` if the node passes all validation criteria, `False` otherwise.
251
267
  """
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
268
+ value = node.get("value", [])
269
+ dates = node.get("dates", [])
270
+ return all([
271
+ node_term_match(node, ORGANIC_CARBON_PER_HA_TERM_ID),
272
+ node.get("depthUpper") == DEPTH_UPPER,
273
+ node.get("depthLower") == DEPTH_LOWER,
274
+ node.get("methodClassification") in (m.value for m in VALID_MEASUREMENT_METHOD_CLASSIFICATIONS),
275
+ len(value) > 0,
276
+ len(value) == len(dates),
277
+ all(_get_datestr_format(datestr) in VALID_DATE_FORMATS for datestr in dates)
278
+ ])
257
279
 
258
280
 
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:
281
+ def _compile_inventory(cycle_id: str, cycles: list[dict], soc_measurements: list[dict]) -> tuple[dict, dict]:
266
282
  """
267
- Linearly interpolate the SocStock value for a specific year between two given years.
283
+ Compile an annual inventory of SOC stocks, SOC stock changes, SOC stock change emissions and the share of emissions
284
+ of cycles.
268
285
 
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.
286
+ A separate inventory is compiled for each valid `MeasurementMethodClassification` present in the input data, which
287
+ are then merged together by selecting the strongest available method for each relevant inventory year.
288
+
289
+ The returned inventory has the shape:
290
+ ```
291
+ {
292
+ year (int): {
293
+ _InventoryKey.SOC_STOCK: value (SocStock),
294
+ _InventoryKey.SOC_STOCK_CHANGE: value (SocStockChange),
295
+ _InventoryKey.CO2_EMISSION: value (SocStockChangeEmission),
296
+ _InventoryKey.SHARE_OF_EMISSION: {
297
+ cycle_id (str): value (float),
298
+ ...cycle_ids
299
+ }
300
+ },
301
+ ...years
302
+ }
303
+ ```
271
304
 
272
305
  Parameters
273
306
  ----------
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.
307
+ cycle_id : str
308
+ cycles : list[dict]
309
+ soc_measurements: list[dict]
284
310
 
285
311
  Returns
286
312
  -------
287
- SocStock
288
- The interpolated `SocStock` for the specified year.
313
+ tuple[dict, dict]
314
+ `(inventory, logs)`
289
315
  """
290
- METHOD = MeasurementMethodClassification.TIER_1_MODEL
316
+ cycle_inventory = _compile_cycle_inventory(cycles)
291
317
 
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
318
+ soc_measurements_by_method = group_measurements_by_method_classification(soc_measurements)
319
+ soc_inventory = {
320
+ method: _compile_soc_inventory(soc_measurements)
321
+ for method, soc_measurements in soc_measurements_by_method.items()
322
+ }
295
323
 
296
- return SocStock(value, METHOD)
324
+ logs = {
325
+ "cycle_inventory": _format_cycle_inventory(cycle_inventory),
326
+ "soc_inventory": _format_soc_inventory(soc_inventory)
327
+ }
297
328
 
329
+ inventory = _squash_inventory(cycle_id, cycle_inventory, soc_inventory)
330
+ return inventory, logs
298
331
 
299
- def _calc_soc_stock_change(start_soc_stock: SocStock, end_soc_stock: SocStock) -> SocStock:
332
+
333
+ def _compile_cycle_inventory(cycles: list[dict]) -> dict:
300
334
  """
301
- Calculate the change in SOC stock change between the current and previous states.
335
+ Calculate grouped share of emissions for cycles based on the amount they contribute the the overall land management
336
+ of an inventory year.
302
337
 
303
- The method should be the weaker of the two `MeasurementMethodClassification`s.
338
+ This function groups cycles by year, then calculates the share of emissions for each cycle based on the
339
+ "fraction_of_group_duration" value. The share of emissions is normalized by the sum of cycle occupancies for the
340
+ entire dataset to ensure the values represent a valid share.
341
+
342
+ The returned inventory has the shape:
343
+ ```
344
+ {
345
+ year (int): {
346
+ _InventoryKey.SHARE_OF_EMISSION: {
347
+ cycle_id (str): value (float),
348
+ ...cycle_ids
349
+ }
350
+ },
351
+ ...years
352
+ }
353
+ ```
304
354
 
305
355
  Parameters
306
356
  ----------
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).
357
+ cycles : list[dict]
358
+ List of [Cycle nodes](https://www.hestia.earth/schema/Cycle), where each cycle dictionary should contain a
359
+ "fraction_of_group_duration" key added by the `group_nodes_by_year` function.
312
360
 
313
361
  Returns
314
362
  -------
315
- SocStock
316
- The SOC stock change (kg C ha-1).
363
+ dict
364
+ A dictionary with grouped share of emissions for each cycle based on the fraction of the year.
317
365
  """
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)
366
+ grouped_cycles = group_nodes_by_year(cycles)
367
+ return {
368
+ year: {
369
+ _InventoryKey.SHARE_OF_EMISSION: {
370
+ cycle["@id"]: (
371
+ cycle.get("fraction_of_group_duration", 0)
372
+ / sum(cycle.get("fraction_of_group_duration", 0) for cycle in cycles)
373
+ ) for cycle in cycles
374
+ }
375
+ } for year, cycles in grouped_cycles.items()
376
+ }
322
377
 
323
378
 
324
- def _convert_c_to_co2(kg_c: float) -> float:
379
+ def _compile_soc_inventory(soc_measurements: list[dict]) -> dict:
325
380
  """
326
- Convert mass of carbon (C) to carbon dioxide (CO2) using the atomic conversion ratio.
381
+ Compile an annual inventory of SOC stock data and pre-computed SOC stock change emissions.
327
382
 
328
- n.b. `get_atomic_conversion` returns the ratio C:CO2 (~44/12).
383
+ The returned inventory has the shape:
384
+ ```
385
+ {
386
+ year (int): {
387
+ _InventoryKey.SOC_STOCK: value (SocStock),
388
+ _InventoryKey.SOC_STOCK_CHANGE: value (SocStockChange),
389
+ _InventoryKey.CO2_EMISSION: value (SocStockChangeEmission)
390
+ },
391
+ ...years
392
+ }
393
+ ```
329
394
 
330
395
  Parameters
331
396
  ----------
332
- kg_c : float
333
- Mass of carbon (C) to be converted to carbon dioxide (CO2) (kg C).
397
+ soc_measurements : list[dict]
398
+ List of pre-validated `organicCarbonPerHa` [Measurement nodes](https://www.hestia.earth/schema/Measurement).
334
399
 
335
400
  Returns
336
401
  -------
337
- float
338
- Mass of carbon dioxide (CO2) resulting from the conversion (kg CO2).
402
+ dict
403
+ The annual inventory.
339
404
  """
340
- return kg_c * get_atomic_conversion(Units.KG_CO2, Units.TO_C)
341
405
 
406
+ values = flatten(measurement.get("value", []) for measurement in soc_measurements)
407
+ dates = flatten(
408
+ [_gapfill_datestr(datestr, DatestrGapfillMode.END) for datestr in measurement.get("dates", [])]
409
+ for measurement in soc_measurements
410
+ )
411
+ methods = flatten(
412
+ [MeasurementMethodClassification(measurement.get("methodClassification")) for _ in measurement.get("value", [])]
413
+ for measurement in soc_measurements
414
+ )
342
415
 
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.
416
+ soc_stocks = sorted(
417
+ [SocStock(value, datestr, method) for value, datestr, method in zip(values, dates, methods)],
418
+ key=lambda soc_stock: soc_stock.date
419
+ )
349
420
 
350
- Parameters
351
- ----------
352
- soc_stock_change_value : float
353
- The change in SOC stock value.
421
+ def interpolate_between(result: dict, soc_stock_pair: tuple[SocStock, SocStock]) -> dict:
422
+ start, end = soc_stock_pair[0], soc_stock_pair[1]
354
423
 
355
- share_of_emission : float
356
- The share of emission associated with the SOC stock change.
424
+ start_date = safe_parse_date(start.date, datetime.min)
425
+ end_date = safe_parse_date(end.date, datetime.min)
357
426
 
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)
427
+ should_run = (
428
+ datetime.min != start_date != end_date
429
+ and end_date > start_date
430
+ )
364
431
 
432
+ update = {
433
+ year: {_InventoryKey.SOC_STOCK: lerp_soc_stocks(start, end, f"{year}-12-31T23:59:59")}
434
+ for year in range(start_date.year, end_date.year+1)
435
+ } if should_run else {}
365
436
 
366
- def _sorted_merge(*sources: Union[dict, list[dict]]) -> dict:
437
+ return result | update
438
+
439
+ soc_stocks_by_year = reduce(interpolate_between, pairwise(soc_stocks), dict())
440
+
441
+ soc_stock_changes_by_year = {
442
+ year: {
443
+ _InventoryKey.SOC_STOCK_CHANGE: calc_soc_stock_change(
444
+ start_group[_InventoryKey.SOC_STOCK],
445
+ end_group[_InventoryKey.SOC_STOCK]
446
+ )
447
+ } for (_, start_group), (year, end_group) in pairwise(soc_stocks_by_year.items())
448
+ }
449
+
450
+ co2_emissions_by_method_and_year = {
451
+ year: {
452
+ _InventoryKey.CO2_EMISSION: calc_soc_stock_change_emission(
453
+ group[_InventoryKey.SOC_STOCK_CHANGE]
454
+ )
455
+ } for year, group in soc_stock_changes_by_year.items()
456
+ }
457
+
458
+ return _sorted_merge(soc_stocks_by_year, soc_stock_changes_by_year, co2_emissions_by_method_and_year)
459
+
460
+
461
+ def lerp_soc_stocks(start: SocStock, end: SocStock, target_date: str) -> SocStock:
367
462
  """
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.
463
+ Estimate, using linear interpolation, an SOC stock for a specific date based on the the SOC stocks of two other
464
+ dates.
370
465
 
371
466
  Parameters
372
467
  ----------
373
- *sources : dict | List[dict]
374
- One or more dictionaries or lists of dictionaries to be merged.
468
+ start : SocStock
469
+ The `SocStock` at the start (kg C ha-1).
470
+ end : SocStock
471
+ The `SocStock` at the end (kg C ha-1).
472
+ target_date : str
473
+ The target date for interpolation as a datestr with format `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or
474
+ `YYYY-MM-DDTHH:mm:ss`.
375
475
 
376
476
  Returns
377
477
  -------
378
- dict
379
- A new dictionary containing the merged key-value pairs, with keys sorted.
478
+ SocStock
479
+ The interpolated `SocStock` for the specified date (kg C ha-1).
380
480
  """
481
+ time_ratio = diff_in_days(start.date, target_date) / diff_in_days(start.date, end.date)
482
+ soc_delta = (end.value - start.value) * time_ratio
381
483
 
382
- _sources = non_empty_list(
383
- flatten([arg if isinstance(arg, list) else [arg] for arg in sources])
384
- )
484
+ value = start.value + soc_delta
485
+ method = min_measurement_method_classification(start.method, end.method)
385
486
 
386
- merged = reduce(merge, _sources, {})
387
- return dict(sorted(merged.items()))
487
+ return SocStock(value, target_date, method)
388
488
 
389
489
 
390
- def _validate_soc_measurement_node(node: dict) -> bool:
490
+ def calc_soc_stock_change(start: SocStock, end: SocStock) -> SocStockChange:
391
491
  """
392
- Validate a SOC measurement node against specified criteria.
492
+ Calculate the change in SOC stock change between the current and previous states.
493
+
494
+ The method should be the weaker of the two `MeasurementMethodClassification`s.
393
495
 
394
496
  Parameters
395
497
  ----------
396
- node : dict
397
- The SOC [Measurement node](https://www.hestia.earth/schema/Measurement) to be validated.
498
+ start : SocStock
499
+ The SOC stock at the start (kg C ha-1).
500
+
501
+ end : SocStock
502
+ The SOC stock at the end (kg C ha-1).
398
503
 
399
504
  Returns
400
505
  -------
401
- bool
402
- True if the node passes all validation criteria, False otherwise.
506
+ SocStockChange
507
+ The SOC stock change (kg C ha-1).
403
508
  """
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
- ])
509
+ value = end.value - start.value
510
+ method = min_measurement_method_classification(start.method, end.method)
511
+ return SocStockChange(value, start.date, end.date, method)
409
512
 
410
513
 
411
- def _nodes_to_soc_stock(year: int, nodes: list[dict]) -> SocStock:
514
+ def calc_soc_stock_change_emission(soc_stock_change: SocStockChange) -> SocStockChangeEmission:
412
515
  """
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`.
516
+ Convert an `SocStockChange` into an `SocStockChangeEmission`.
417
517
 
418
518
  Parameters
419
519
  ----------
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.
520
+ soc_stock_change : SocStockChange
521
+ The SOC stock at the start (kg C ha-1).
425
522
 
426
523
  Returns
427
524
  -------
428
- SocStock
429
- The calculated SOC stock for the specified year.
525
+ SocStockChangeEmission
526
+ The SOC stock change emission (kg CO2 ha-1).
430
527
  """
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
- )
528
+ value = convert_c_to_co2(soc_stock_change.value) * -1
529
+ method = convert_mmc_to_emt(soc_stock_change.method)
530
+ return SocStockChangeEmission(value, soc_stock_change.start_date, soc_stock_change.end_date, method)
448
531
 
449
- value = next(
450
- (value for value, method in zip(values, methods) if method == closest_method.value), 0
451
- )
452
532
 
453
- return SocStock(value, closest_method)
533
+ _DEFAULT_EMISSION_METHOD_TIER = EmissionMethodTier.TIER_1
534
+ _MEASUREMENT_METHOD_CLASSIFICATION_TO_EMISSION_METHOD_TIER = {
535
+ MeasurementMethodClassification.TIER_2_MODEL: EmissionMethodTier.TIER_2,
536
+ MeasurementMethodClassification.TIER_3_MODEL: EmissionMethodTier.TIER_3,
537
+ MeasurementMethodClassification.MODELLED_USING_OTHER_MEASUREMENTS: EmissionMethodTier.MEASURED,
538
+ MeasurementMethodClassification.ON_SITE_PHYSICAL_MEASUREMENT: EmissionMethodTier.MEASURED,
539
+ }
540
+ """
541
+ A mapping between `MeasurementMethodClassification`s and `EmissionMethodTier`s. As SOC measurements can be
542
+ measured/estimated through a variety of methods, the emission model needs be able to assign an emission tier for each.
543
+ Any `MeasurementMethodClassification` not in the mapping should be assigned `DEFAULT_EMISSION_METHOD_TIER`.
544
+ """
454
545
 
455
546
 
456
- def _group_soc_stocks(site: dict) -> dict:
547
+ def convert_mmc_to_emt(
548
+ measurement_method_classification: MeasurementMethodClassification
549
+ ) -> EmissionMethodTier:
457
550
  """
458
- Group valid `organicCarbonPerHa` measurement nodes by year (based on node "dates" field) and reduce them to a
459
- single `SocStock` for each year.
551
+ Get the emission method tier based on the provided measurement method classification.
460
552
 
461
553
  Parameters
462
554
  ----------
463
- site : dict
464
- A [Site node](https://www.hestia.earth/schema/Cycle).
555
+ measurement_method : MeasurementMethodClassification
556
+ The measurement method classification.
465
557
 
466
558
  Returns
467
559
  -------
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.
560
+ EmissionMethodTier
561
+ The corresponding emission method tier.
471
562
  """
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
563
+ return _MEASUREMENT_METHOD_CLASSIFICATION_TO_EMISSION_METHOD_TIER.get(
564
+ to_measurement_method_classification(measurement_method_classification),
565
+ _DEFAULT_EMISSION_METHOD_TIER
477
566
  )
478
567
 
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
568
 
488
- def _interpolate_grouped_soc_stocks(grouped_soc_stocks: dict) -> dict:
569
+ def convert_c_to_co2(kg_c: float) -> float:
489
570
  """
490
- Interpolate SOC stocks for years between grouped SOC stock data.
571
+ Convert mass of carbon (C) to carbon dioxide (CO2) using the atomic conversion ratio.
491
572
 
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.
573
+ n.b. `get_atomic_conversion` returns the ratio C:CO2 (~44/12).
495
574
 
496
575
  Parameters
497
576
  ----------
498
- grouped_soc_stocks : dict
499
- Dictionary containing grouped SOC stock data with years as keys.
577
+ kg_c : float
578
+ Mass of carbon (C) to be converted to carbon dioxide (CO2) (kg C).
500
579
 
501
580
  Returns
502
581
  -------
503
- dict
504
- A dictionary with interpolated SOC stock data for missing years.
582
+ float
583
+ Mass of carbon dioxide (CO2) resulting from the conversion (kg CO2).
505
584
  """
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())
585
+ return kg_c * get_atomic_conversion(Units.KG_CO2, Units.TO_C)
525
586
 
526
587
 
527
- def _get_grouped_soc_stocks(site: dict) -> dict:
588
+ def _sorted_merge(*sources: Union[dict, list[dict]]) -> dict:
528
589
  """
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.
590
+ Merge dictionaries and return the result as a new dictionary with keys sorted in order to preserve the temporal
591
+ order of inventory years.
533
592
 
534
593
  Parameters
535
594
  ----------
536
- site : dict
537
- The site dictionary containing SOC measurements.
595
+ *sources : dict | list[dict]
596
+ One or more dictionaries or lists of dictionaries to be merged.
538
597
 
539
598
  Returns
540
599
  -------
541
600
  dict
542
- A dictionary with grouped and interpolated SOC stock data for all years.
601
+ A new dictionary containing the merged key-value pairs, with keys sorted.
543
602
  """
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
603
 
604
+ _sources = non_empty_list(
605
+ flatten([arg if isinstance(arg, list) else [arg] for arg in sources])
606
+ )
548
607
 
549
- def _calc_grouped_soc_stock_changes(grouped_soc_stocks: dict) -> dict:
608
+ merged = reduce(merge, _sources, {})
609
+ return dict(sorted(merged.items()))
610
+
611
+
612
+ def _squash_inventory(cycle_id: str, cycle_inventory: dict, soc_inventory: dict) -> dict:
550
613
  """
551
- Calculate SOC stock changes between grouped SOC stock data for consecutive years.
614
+ Merge the `cycle_inventory` and `soc_inventory` for each inventory year by selecting the strongest available
615
+ `MeasurementMethodClassification`. Years that do not overlap with the Cycle node that the emission model is running
616
+ on should be discarded as they are not relevant.
552
617
 
553
618
  Parameters
554
619
  ----------
555
- grouped_soc_stocks : dict
556
- Dictionary containing grouped SOC stock data with years as keys.
620
+ cycle_id : str
621
+ cycle_inventory : dict
622
+ soc_inventory: dict
557
623
 
558
624
  Returns
559
625
  -------
560
626
  dict
561
- A dictionary with calculated SOC stock changes for consecutive years.
627
+ The squashed inventory.
628
+ """
629
+ inventory_years = sorted(set(non_empty_list(
630
+ flatten(list(years) for years in soc_inventory.values())
631
+ + list(cycle_inventory.keys())
632
+ )))
633
+
634
+ def should_run_group(method: MeasurementMethodClassification, year: int) -> bool:
635
+ soc_stock_inventory_group = soc_inventory.get(method, {}).get(year, {})
636
+ share_of_emissions_group = cycle_inventory.get(year, {})
637
+
638
+ has_emission = _InventoryKey.CO2_EMISSION in soc_stock_inventory_group.keys()
639
+ is_relevant_for_cycle = cycle_id in share_of_emissions_group.get(_InventoryKey.SHARE_OF_EMISSION, {}).keys()
640
+ return all([has_emission, is_relevant_for_cycle])
641
+
642
+ def squash(result: dict, year: int) -> dict:
643
+ update_dict = next(
644
+ (
645
+ {year: reduce(merge, [soc_inventory[method][year], cycle_inventory[year]], dict())}
646
+ for method in VALID_MEASUREMENT_METHOD_CLASSIFICATIONS if should_run_group(method, year)
647
+ ),
648
+ {}
649
+ )
650
+ return result | update_dict
651
+
652
+ return reduce(squash, inventory_years, dict())
653
+
654
+
655
+ def _format_cycle_inventory(cycle_inventory: dict) -> str:
656
+ """
657
+ Format the cycle inventory for logging as a table. Rows represent inventory years, columns represent the share of
658
+ emission for each cycle present in the inventory. If the inventory is invalid, return `"None"` as a string.
562
659
  """
563
- INNER_KEY = _InnerKey.SOC_STOCK_CHANGE
660
+ KEY = _InventoryKey.SHARE_OF_EMISSION
564
661
 
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]
662
+ unique_cycles = sorted(
663
+ set(non_empty_list(flatten(list(group[KEY]) for group in cycle_inventory.values()))),
664
+ key=lambda id: next((year, id) for year in cycle_inventory if id in cycle_inventory[year][KEY])
665
+ )
568
666
 
569
- current_soc_stock = grouped_soc_stocks[current_year][_InnerKey.SOC_STOCK]
570
- prev_soc_stock = grouped_soc_stocks[prev_year][_InnerKey.SOC_STOCK]
667
+ should_run = cycle_inventory and len(unique_cycles) > 0
571
668
 
572
- return data | {
573
- current_year: {
574
- INNER_KEY: _calc_soc_stock_change(prev_soc_stock, current_soc_stock)
669
+ return log_as_table(
670
+ {
671
+ "year": year,
672
+ **{
673
+ id: _format_number(group.get(KEY, {}).get(id, 0)) for id in unique_cycles
575
674
  }
576
- }
577
-
578
- return reduce(group_changes, range(1, len(grouped_soc_stocks)), dict())
675
+ } for year, group in cycle_inventory.items()
676
+ ) if should_run else "None"
579
677
 
580
678
 
581
- def _calc_sum_cycle_occupancy(cycles: list[dict]) -> float:
679
+ def _format_soc_inventory(soc_inventory: dict) -> str:
582
680
  """
583
- Calculate the sum of cycle occupancies based on the `fraction_of_group_duration` field added by the
584
- `group_nodes_by_year` function.
681
+ Format the SOC inventory for logging as a table. Rows represent inventory years, columns represent soc stock change
682
+ data for each measurement method classification present in inventory. If the inventory is invalid, return `"None"`
683
+ as a string.
684
+ """
685
+ KEYS = [
686
+ _InventoryKey.SOC_STOCK,
687
+ _InventoryKey.SOC_STOCK_CHANGE,
688
+ _InventoryKey.CO2_EMISSION
689
+ ]
585
690
 
586
- If a cycle does not have the "fraction_of_group_duration" key, it is treated as zero occupancy for that year.
691
+ methods = soc_inventory.keys()
692
+ method_columns = list(product(methods, KEYS))
693
+ inventory_years = sorted(set(non_empty_list(flatten(list(years) for years in soc_inventory.values()))))
587
694
 
588
- Parameters
589
- ----------
590
- cycles : List[dict]
591
- List of cycles, where each cycle dictionary should contain a "fraction_of_group_duration" key.
695
+ should_run = soc_inventory and len(inventory_years) > 0
592
696
 
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)
697
+ return log_as_table(
698
+ {
699
+ "year": year,
700
+ **{
701
+ _format_column_header(method, key): _format_named_tuple(
702
+ soc_inventory.get(method, {}).get(year, {}).get(key, {})
703
+ ) for method, key in method_columns
704
+ }
705
+ } for year in inventory_years
706
+ ) if should_run else "None"
599
707
 
600
708
 
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.
709
+ def _format_number(value: Optional[float]) -> str:
710
+ """Format a float for logging in a table. If the value is invalid, return `"None"` as a string."""
711
+ return f"{value:.1f}" if isinstance(value, (float, int)) else "None"
605
712
 
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
713
 
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.
714
+ def _format_column_header(method: MeasurementMethodClassification, inventory_key: _InventoryKey) -> str:
715
+ """
716
+ Format a measurement method classification and inventory key for logging in a table as a column header. Replace any
717
+ whitespaces in the method value with dashes and concatenate it with the inventory key value, which already has the
718
+ correct format.
719
+ """
720
+ return "-".join([
721
+ method.value.replace(" ", "-"),
722
+ inventory_key.value
723
+ ])
615
724
 
616
- Returns
617
- -------
618
- dict
619
- A dictionary with grouped share of emissions for each cycle based on the fraction of the year.
725
+
726
+ def _format_named_tuple(value: Optional[Union[SocStock, SocStockChange, SocStockChangeEmission]]) -> str:
620
727
  """
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
- }
728
+ Format a named tuple (`SocStock`, `SocStockChange` or `SocStockChangeEmission`) for logging in a table. Extract and
729
+ format just the value and discard the other data. If the value is invalid, return `"None"` as a string.
730
+ """
731
+ return (
732
+ _format_number(value.value)
733
+ if isinstance(value, (SocStock, SocStockChange, SocStockChangeEmission))
734
+ else "None"
735
+ )
631
736
 
632
737
 
633
- def _run(cycle_id: str, grouped_data: dict) -> list[dict]:
738
+ def _run(cycle_id: str, inventory: dict) -> list[dict]:
634
739
  """
635
740
  Calculate emissions for a specific cycle using grouped SOC stock change and share of emissions data.
636
741
 
@@ -641,7 +746,6 @@ def _run(cycle_id: str, grouped_data: dict) -> list[dict]:
641
746
  ----------
642
747
  cycle_id : str
643
748
  The "@id" field of the [Cycle node](https://www.hestia.earth/schema/Cycle).
644
-
645
749
  grouped_data : dict
646
750
  A dictionary containing grouped SOC stock change and share of emissions data.
647
751
 
@@ -650,110 +754,59 @@ def _run(cycle_id: str, grouped_data: dict) -> list[dict]:
650
754
  list[dict]
651
755
  A list containing emission data calculated for the specified cycle.
652
756
  """
757
+ total_emission = _sum_soc_stock_change_emissions([
758
+ _rescale_stock_change_emission(
759
+ group[_InventoryKey.CO2_EMISSION], group[_InventoryKey.SHARE_OF_EMISSION][cycle_id]
760
+ ) for group in inventory.values()
761
+ ])
653
762
 
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
-
763
+ value = round(total_emission.value, 6)
764
+ method_tier = total_emission.method
667
765
  return [_emission(value, method_tier)]
668
766
 
669
767
 
670
- def get_site(cycle: dict) -> dict:
671
- return cycle.get("site", {})
672
-
673
-
674
- def _should_run(cycle: dict) -> tuple:
768
+ def _rescale_stock_change_emission(emission: SocStockChangeEmission, factor: float) -> SocStockChangeEmission:
675
769
  """
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.
770
+ Rescale an `SocStockChangeEmission` by a specified factor.
683
771
 
684
772
  Parameters
685
773
  ----------
686
- cycle : dict
687
- The cycle dictionary for which the calculations will be evaluated.
774
+ emission : SocStockChangeEmission
775
+ An SOC stock change emission (kg CO2 ha-1).
776
+ factor : float
777
+ A scaling factor (e.g., a [Cycles](https://www.hestia.earth/schema/Cycle)'s share of an annual emission).
688
778
 
689
779
  Returns
690
780
  -------
691
- tuple
692
- A tuple containing a boolean indicating whether calculations should run,
693
- the cycle identifier, and grouped SOC stock and emissions data.
781
+ SocStockChangeEmission
782
+ The rescaled emission.
694
783
  """
695
- cycle_id = cycle.get("@id")
696
- site = get_site(cycle)
697
- cycles = related_cycles(site)
784
+ value = emission.value * factor
785
+ return SocStockChangeEmission(value, emission.start_date, emission.end_date, emission.method)
698
786
 
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
787
 
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)
788
+ def _sum_soc_stock_change_emissions(emissions: Iterable[SocStockChangeEmission]) -> SocStockChangeEmission:
789
+ """
790
+ Sum together multiple `SocStockChangeEmission`s.
719
791
 
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
- )
792
+ Parameters
793
+ ----------
794
+ emissions : Iterable[SocStockChangeEmission]
795
+ A list of SOC stock change emissions (kg CO2 ha-1).
749
796
 
750
- should_run = has_functional_unit_1_ha and has_valid_inventory
751
- logShouldRun(cycle, MODEL, TERM_ID, should_run)
797
+ Returns
798
+ -------
799
+ SocStockChangeEmission
800
+ The summed emission.
801
+ """
802
+ value = sum(e.value for e in emissions)
803
+ start_date = min(e.start_date for e in emissions)
804
+ end_date = max(e.end_date for e in emissions)
805
+ method = min_emission_method_tier(e.method for e in emissions)
752
806
 
753
- return should_run, cycle_id, {year: group for year, group in inventory.items() if year in valid_years}
807
+ return SocStockChangeEmission(value, start_date, end_date, method)
754
808
 
755
809
 
756
810
  def run(cycle: dict) -> list[dict]:
757
811
  should_run, *args = _should_run(cycle)
758
-
759
812
  return _run(*args) if should_run else []