hestia-earth-models 0.64.6__py3-none-any.whl → 0.64.8__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 (37) hide show
  1. hestia_earth/models/cycle/animal/milkYield.py +10 -22
  2. hestia_earth/models/cycle/unknownPreSeasonWaterRegime.py +0 -1
  3. hestia_earth/models/environmentalFootprintV3/soilQualityIndexLandOccupation.py +25 -24
  4. hestia_earth/models/environmentalFootprintV3/soilQualityIndexLandTransformation.py +182 -0
  5. hestia_earth/models/environmentalFootprintV3/soilQualityIndexTotalLandUseEffects.py +66 -0
  6. hestia_earth/models/environmentalFootprintV3/utils.py +1 -1
  7. hestia_earth/models/hyde32/utils.py +4 -0
  8. hestia_earth/models/ipcc2019/animal/pastureGrass.py +3 -1
  9. hestia_earth/models/ipcc2019/co2ToAirAboveGroundBiomassStockChangeLandUseChange.py +191 -0
  10. hestia_earth/models/ipcc2019/co2ToAirBelowGroundBiomassStockChangeLandUseChange.py +204 -0
  11. hestia_earth/models/ipcc2019/co2ToAirCarbonStockChange_utils.py +255 -35
  12. hestia_earth/models/ipcc2019/co2ToAirSoilOrganicCarbonStockChangeManagementChange.py +63 -149
  13. hestia_earth/models/ipcc2019/pastureGrass.py +3 -1
  14. hestia_earth/models/mocking/search-results.json +337 -319
  15. hestia_earth/models/pooreNemecek2018/landOccupationDuringCycle.py +12 -8
  16. hestia_earth/models/site/management.py +3 -5
  17. hestia_earth/models/transformation/input/excreta.py +9 -13
  18. hestia_earth/models/utils/input.py +5 -2
  19. hestia_earth/models/utils/site.py +4 -2
  20. hestia_earth/models/version.py +1 -1
  21. {hestia_earth_models-0.64.6.dist-info → hestia_earth_models-0.64.8.dist-info}/METADATA +2 -2
  22. {hestia_earth_models-0.64.6.dist-info → hestia_earth_models-0.64.8.dist-info}/RECORD +37 -29
  23. tests/models/cycle/animal/test_milkYield.py +1 -14
  24. tests/models/environmentalFootprintV3/test_freshwaterEcotoxicityPotentialCtue.py +4 -2
  25. tests/models/environmentalFootprintV3/test_soilQualityIndexLandOccupation.py +16 -24
  26. tests/models/environmentalFootprintV3/test_soilQualityIndexLandTransformation.py +113 -0
  27. tests/models/environmentalFootprintV3/test_soilQualityIndexTotalLandUseEffects.py +50 -0
  28. tests/models/ipcc2019/test_co2ToAirAboveGroundBiomassStockChangeLandUseChange.py +83 -0
  29. tests/models/ipcc2019/test_co2ToAirBelowGroundBiomassStockChangeLandUseChange.py +83 -0
  30. tests/models/ipcc2019/test_co2ToAirCarbonStockChange_utils.py +6 -6
  31. tests/models/ipcc2019/test_co2ToAirSoilOrganicCarbonStockChangeManagementChange.py +5 -4
  32. tests/models/pooreNemecek2018/test_landOccupationDuringCycle.py +4 -1
  33. tests/models/site/test_management.py +4 -1
  34. tests/models/utils/test_input.py +65 -1
  35. {hestia_earth_models-0.64.6.dist-info → hestia_earth_models-0.64.8.dist-info}/LICENSE +0 -0
  36. {hestia_earth_models-0.64.6.dist-info → hestia_earth_models-0.64.8.dist-info}/WHEEL +0 -0
  37. {hestia_earth_models-0.64.6.dist-info → hestia_earth_models-0.64.8.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,204 @@
1
+ from hestia_earth.schema import CycleFunctionalUnit, EmissionMethodTier, SiteSiteType
2
+
3
+ from hestia_earth.models.log import logRequirements, logShouldRun
4
+ from hestia_earth.models.utils.blank_node import cumulative_nodes_term_match
5
+ from hestia_earth.models.utils.emission import _new_emission
6
+
7
+ from .co2ToAirCarbonStockChange_utils import create_run_function, create_should_run_function
8
+ from . import MODEL
9
+
10
+ REQUIREMENTS = {
11
+ "Cycle": {
12
+ "site": {
13
+ "measurements": [
14
+ {
15
+ "@type": "Measurement",
16
+ "value": "",
17
+ "dates": "",
18
+ "depthUpper": "0",
19
+ "depthLower": "30",
20
+ "term.@id": " belowGroundBiomass"
21
+ }
22
+ ]
23
+ },
24
+ "functionalUnit": "1 ha",
25
+ "endDate": "",
26
+ "optional": {
27
+ "startDate": ""
28
+ }
29
+ }
30
+ }
31
+ RETURNS = {
32
+ "Emission": [{
33
+ "value": "",
34
+ "sd": "",
35
+ "min": "",
36
+ "max": "",
37
+ "statsDefinition": "simulated",
38
+ "observations": "",
39
+ "methodTier": "",
40
+ "depth": "30"
41
+ }]
42
+ }
43
+ TERM_ID = 'co2ToAirBelowGroundBiomassStockChangeLandUseChange'
44
+
45
+ _DEPTH_UPPER = 0
46
+ _DEPTH_LOWER = 30
47
+
48
+ _CARBON_STOCK_TERM_ID = 'belowGroundBiomass'
49
+
50
+ _SITE_TYPE_SYSTEMS_MAPPING = {
51
+ SiteSiteType.GLASS_OR_HIGH_ACCESSIBLE_COVER.value: [
52
+ "protectedCroppingSystemSoilBased",
53
+ "protectedCroppingSystemSoilAndSubstrateBased"
54
+ ]
55
+ }
56
+
57
+
58
+ def _emission(
59
+ *,
60
+ value: list[float],
61
+ method_tier: EmissionMethodTier,
62
+ sd: list[float] = None,
63
+ min: list[float] = None,
64
+ max: list[float] = None,
65
+ statsDefinition: str = None,
66
+ observations: list[int] = None
67
+ ) -> dict:
68
+ """
69
+ Create an emission node based on the provided value and method tier.
70
+
71
+ See [Emission schema](https://www.hestia.earth/schema/Emission) for more information.
72
+
73
+ Parameters
74
+ ----------
75
+ value : float
76
+ The emission value (kg CO2 ha-1).
77
+ sd : float
78
+ The standard deviation (kg CO2 ha-1).
79
+ method_tier : EmissionMethodTier
80
+ The emission method tier.
81
+
82
+ Returns
83
+ -------
84
+ dict
85
+ The emission dictionary with keys 'depth', 'value', and 'methodTier'.
86
+ """
87
+ update_dict = {
88
+ "value": value,
89
+ "sd": sd,
90
+ "min": min,
91
+ "max": max,
92
+ "statsDefinition": statsDefinition,
93
+ "observations": observations,
94
+ "methodTier": method_tier.value,
95
+ "depth": _DEPTH_LOWER
96
+ }
97
+ emission = _new_emission(TERM_ID, MODEL) | {
98
+ key: value for key, value in update_dict.items() if value
99
+ }
100
+ return emission
101
+
102
+
103
+ def run(cycle: dict) -> list[dict]:
104
+ """
105
+ Run the `ipcc2019.co2ToAirBelowGroundBiomassStockChangeManagementChange`.
106
+
107
+ Parameters
108
+ ----------
109
+ cycle : dict
110
+ A HESTIA (Cycle node)[https://www.hestia.earth/schema/Cycle].
111
+
112
+ Returns
113
+ -------
114
+ list[dict]
115
+ A list of [Emission nodes](https://www.hestia.earth/schema/Emission) containing model results.
116
+ """
117
+ should_run_exec = create_should_run_function(
118
+ _CARBON_STOCK_TERM_ID,
119
+ _should_compile_inventory_func,
120
+ should_run_measurement_func=_should_run_measurement_func
121
+ )
122
+
123
+ run_exec = create_run_function(_emission)
124
+
125
+ should_run, cycle_id, inventory, logs = should_run_exec(cycle)
126
+
127
+ logRequirements(cycle, model=MODEL, term=TERM_ID, **logs)
128
+ logShouldRun(cycle, MODEL, TERM_ID, should_run)
129
+
130
+ return run_exec(cycle_id, inventory) if should_run else []
131
+
132
+
133
+ def _should_run_measurement_func(node: dict) -> bool:
134
+ """
135
+ Validate a [Measurement](https://www.hestia.earth/schema/Measurement) to determine whether it is a valid
136
+ `organicCarbonPerHa` node.
137
+
138
+ Parameters
139
+ ----------
140
+ node : dict
141
+ The node to be validated.
142
+
143
+ Returns
144
+ -------
145
+ bool
146
+ `True` if the node passes all validation criteria, `False` otherwise.
147
+ """
148
+ return all([
149
+ node.get("depthLower") == _DEPTH_LOWER,
150
+ node.get("depthUpper") == _DEPTH_UPPER
151
+ ])
152
+
153
+
154
+ def _should_compile_inventory_func(
155
+ site: dict, cycles: list[dict], carbon_stock_measurements: list[dict]
156
+ ) -> tuple[bool, dict]:
157
+ """
158
+ Determine whether a site is suitable and has enough data to compile a carbon stock inventory.
159
+
160
+ Parameters
161
+ ----------
162
+ site : dict
163
+ A HESTIA (Site node)[https://www.hestia.earth/schema/Site]
164
+ cycles : list[dict]
165
+ A list of HESTIA (Cycle nodes)[https://www.hestia.earth/schema/Cycle] that are related to the site.
166
+ carbon_stock_measurements : list[dict]
167
+ A list of HESTIA carbon stock (Measurement nodes)[https://www.hestia.earth/schema/Measurement] that are related
168
+ to the site.
169
+
170
+ Returns
171
+ -------
172
+ tuple[bool, dict]
173
+ `(should_run, logs)`.
174
+ """
175
+ site_type = site.get("siteType")
176
+ has_soil = site_type not in _SITE_TYPE_SYSTEMS_MAPPING or all(
177
+ cumulative_nodes_term_match(
178
+ cycle.get("practices", []),
179
+ target_term_ids=_SITE_TYPE_SYSTEMS_MAPPING[site_type],
180
+ cumulative_threshold=0
181
+ ) for cycle in cycles
182
+ )
183
+
184
+ has_stock_measurements = len(carbon_stock_measurements) > 0
185
+ has_cycles = len(cycles) > 0
186
+ has_functional_unit_1_ha = all(cycle.get('functionalUnit') == CycleFunctionalUnit._1_HA.value for cycle in cycles)
187
+
188
+ should_run = all([
189
+ has_soil,
190
+ has_stock_measurements,
191
+ has_cycles,
192
+ has_functional_unit_1_ha
193
+ ])
194
+
195
+ logs = {
196
+ "site_type": site_type,
197
+ "has_soil": has_soil,
198
+ "carbon_stock_term": _CARBON_STOCK_TERM_ID,
199
+ "has_stock_measurements": has_stock_measurements,
200
+ "has_cycles": has_cycles,
201
+ "has_functional_unit_1_ha": has_functional_unit_1_ha,
202
+ }
203
+
204
+ return should_run, logs
@@ -10,28 +10,36 @@ from itertools import product
10
10
  from numpy import array, random, mean
11
11
  from numpy.typing import NDArray
12
12
  from pydash.objects import merge
13
- from typing import NamedTuple, Optional, Union
13
+ from typing import Callable, NamedTuple, Optional, Union
14
14
 
15
- from hestia_earth.schema import EmissionMethodTier, MeasurementMethodClassification
15
+ from hestia_earth.schema import (
16
+ EmissionMethodTier, EmissionStatsDefinition, MeasurementMethodClassification
17
+ )
16
18
  from hestia_earth.utils.date import diff_in_days, YEAR
17
19
  from hestia_earth.utils.tools import flatten, non_empty_list, safe_parse_date
18
20
 
19
21
  from hestia_earth.models.log import log_as_table
20
22
  from hestia_earth.models.utils import pairwise
21
- from hestia_earth.models.utils.array_builders import correlated_normal_2d
23
+ from hestia_earth.models.utils.array_builders import correlated_normal_2d, gen_seed
22
24
  from hestia_earth.models.utils.blank_node import (
23
- _gapfill_datestr, DatestrGapfillMode, group_nodes_by_year, split_node_by_dates
25
+ _gapfill_datestr, _get_datestr_format, DatestrGapfillMode, DatestrFormat, group_nodes_by_year, node_term_match,
26
+ split_node_by_dates
24
27
  )
25
28
  from hestia_earth.models.utils.constant import Units, get_atomic_conversion
29
+ from hestia_earth.models.utils.descriptive_stats import calc_descriptive_stats
26
30
  from hestia_earth.models.utils.emission import min_emission_method_tier
27
31
  from hestia_earth.models.utils.measurement import (
28
32
  group_measurements_by_method_classification, min_measurement_method_classification,
29
33
  to_measurement_method_classification
30
34
  )
35
+ from hestia_earth.models.utils.site import related_cycles
31
36
  from hestia_earth.models.utils.time_series import (
32
37
  calc_tau, compute_time_series_correlation_matrix, exponential_decay
33
38
  )
34
39
 
40
+ from .utils import check_consecutive
41
+
42
+ _ITERATIONS = 10000
35
43
  _MAX_CORRELATION = 1
36
44
  _MIN_CORRELATION = 0.5
37
45
  _NOMINAL_ERROR = 75
@@ -40,7 +48,14 @@ carbon stock measurements without an associated `sd` should be assigned a nomina
40
48
  the mean).
41
49
  """
42
50
  _TRANSITION_PERIOD = 20 * YEAR # 20 years in days
43
- _VALID_MEASUREMENT_METHOD_CLASSIFICATIONS = [
51
+ _VALID_DATE_FORMATS = {
52
+ DatestrFormat.YEAR,
53
+ DatestrFormat.YEAR_MONTH,
54
+ DatestrFormat.YEAR_MONTH_DAY,
55
+ DatestrFormat.YEAR_MONTH_DAY_HOUR_MINUTE_SECOND
56
+ }
57
+
58
+ DEFAULT_MEASUREMENT_METHOD_RANKING = [
44
59
  MeasurementMethodClassification.ON_SITE_PHYSICAL_MEASUREMENT,
45
60
  MeasurementMethodClassification.MODELLED_USING_OTHER_MEASUREMENTS,
46
61
  MeasurementMethodClassification.TIER_3_MODEL,
@@ -52,6 +67,19 @@ The list of `MeasurementMethodClassification`s that can be used to calculate car
52
67
  order from strongest to weakest.
53
68
  """
54
69
 
70
+ _DEFAULT_EMISSION_METHOD_TIER = EmissionMethodTier.TIER_1
71
+ _MEASUREMENT_METHOD_CLASSIFICATION_TO_EMISSION_METHOD_TIER = {
72
+ MeasurementMethodClassification.TIER_2_MODEL: EmissionMethodTier.TIER_2,
73
+ MeasurementMethodClassification.TIER_3_MODEL: EmissionMethodTier.TIER_3,
74
+ MeasurementMethodClassification.MODELLED_USING_OTHER_MEASUREMENTS: EmissionMethodTier.MEASURED,
75
+ MeasurementMethodClassification.ON_SITE_PHYSICAL_MEASUREMENT: EmissionMethodTier.MEASURED,
76
+ }
77
+ """
78
+ A mapping between `MeasurementMethodClassification`s and `EmissionMethodTier`s. As carbon stock measurements can be
79
+ measured/estimated through a variety of methods, the emission model needs be able to assign an emission tier for each.
80
+ Any `MeasurementMethodClassification` not in the mapping should be assigned `DEFAULT_EMISSION_METHOD_TIER`.
81
+ """
82
+
55
83
 
56
84
  class _InventoryKey(Enum):
57
85
  """
@@ -132,7 +160,7 @@ method: MeasurementMethodClassification
132
160
  """
133
161
 
134
162
 
135
- def lerp_carbon_stocks(start: CarbonStock, end: CarbonStock, target_date: str) -> CarbonStock:
163
+ def _lerp_carbon_stocks(start: CarbonStock, end: CarbonStock, target_date: str) -> CarbonStock:
136
164
  """
137
165
  Estimate, using linear interpolation, a carbon stock for a specific date based on the carbon stocks of two other
138
166
  dates.
@@ -158,7 +186,7 @@ def lerp_carbon_stocks(start: CarbonStock, end: CarbonStock, target_date: str) -
158
186
  return CarbonStock(value, target_date, method)
159
187
 
160
188
 
161
- def calc_carbon_stock_change(start: CarbonStock, end: CarbonStock) -> CarbonStockChange:
189
+ def _calc_carbon_stock_change(start: CarbonStock, end: CarbonStock) -> CarbonStockChange:
162
190
  """
163
191
  Calculate the change in a carbon stock between two different dates.
164
192
 
@@ -181,7 +209,7 @@ def calc_carbon_stock_change(start: CarbonStock, end: CarbonStock) -> CarbonStoc
181
209
  return CarbonStockChange(value, start.date, end.date, method)
182
210
 
183
211
 
184
- def calc_carbon_stock_change_emission(carbon_stock_change: CarbonStockChange) -> CarbonStockChangeEmission:
212
+ def _calc_carbon_stock_change_emission(carbon_stock_change: CarbonStockChange) -> CarbonStockChangeEmission:
185
213
  """
186
214
  Convert a `CarbonStockChange` into a `CarbonStockChangeEmission`.
187
215
 
@@ -222,20 +250,6 @@ def _convert_mmc_to_emt(
222
250
  )
223
251
 
224
252
 
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
253
  def _convert_c_to_co2(kg_c: float) -> float:
240
254
  """
241
255
  Convert mass of carbon (C) to carbon dioxide (CO2) using the atomic conversion ratio.
@@ -255,7 +269,7 @@ def _convert_c_to_co2(kg_c: float) -> float:
255
269
  return kg_c * get_atomic_conversion(Units.KG_CO2, Units.TO_C)
256
270
 
257
271
 
258
- def rescale_carbon_stock_change_emission(
272
+ def _rescale_carbon_stock_change_emission(
259
273
  emission: CarbonStockChangeEmission, factor: float
260
274
  ) -> CarbonStockChangeEmission:
261
275
  """
@@ -278,7 +292,7 @@ def rescale_carbon_stock_change_emission(
278
292
  return CarbonStockChangeEmission(value, emission.start_date, emission.end_date, emission.method)
279
293
 
280
294
 
281
- def add_carbon_stock_change_emissions(
295
+ def _add_carbon_stock_change_emissions(
282
296
  emission_1: CarbonStockChangeEmission, emission_2: CarbonStockChangeEmission
283
297
  ) -> CarbonStockChangeEmission:
284
298
  """
@@ -304,12 +318,157 @@ def add_carbon_stock_change_emissions(
304
318
  return CarbonStockChangeEmission(value, start_date, end_date, method)
305
319
 
306
320
 
307
- def compile_inventory(
321
+ def create_should_run_function(
322
+ carbon_stock_term_id: str,
323
+ should_compile_inventory_func: Callable[[dict, list[dict], list[dict]], tuple[bool, dict]],
324
+ should_run_measurement_func: Callable[[dict], bool] = lambda _: True,
325
+ measurement_method_ranking: list[MeasurementMethodClassification] = DEFAULT_MEASUREMENT_METHOD_RANKING
326
+ ) -> Callable[[dict], tuple[bool, str, dict, dict]]:
327
+ """
328
+ Create a should run function for an emissions from carbon stock change model.
329
+
330
+ Model-specific validation functions should be passed as parameters to this higher order function to determine which
331
+ carbon stock measurements are included in the inventory and whether there is enough data to compile an annual
332
+ inventory of carbon stock change data.
333
+
334
+ Parameters
335
+ ----------
336
+ carbon_stock_term_id : str
337
+ The `term.@id` of the carbon stock measurement (e.g., `aboveGroundBiomass`, `belowGroundBiomass`,
338
+ `organicCarbonPerHa`, etc.).
339
+
340
+ should_compile_inventory_func : Callable[[dict, list[dict], list[dict]], tuple[bool, dict]]
341
+ A function, with the signature
342
+ `(site: dict, cycles: list[dict], carbon_stock_measurements: list[dict]) -> (should_run: bool, logs: dict)`, to
343
+ determine whether there is enough site and cycles data available to compile the carbon stock change inventory.
344
+
345
+ should_run_measurement_func : Callable[[dict], bool], optional.
346
+ An optional measurement validation function, with the signature `(measurement: dict) -> bool`, that can be used
347
+ to add in additional criteria (`depthUpper`, `depthLower`, etc.) for the inclusion of a measurement in the
348
+ inventory.
349
+
350
+ measurement_method_ranking : list[MeasurementMethodClassification], optional
351
+ The order in which to prioritise `MeasurementMethodClassification`s when reducing the inventory down to a
352
+ single method per year. Defaults to:
353
+ ```
354
+ MeasurementMethodClassification.ON_SITE_PHYSICAL_MEASUREMENT,
355
+ MeasurementMethodClassification.MODELLED_USING_OTHER_MEASUREMENTS,
356
+ MeasurementMethodClassification.TIER_3_MODEL,
357
+ MeasurementMethodClassification.TIER_2_MODEL,
358
+ MeasurementMethodClassification.TIER_1_MODEL
359
+ ```
360
+ n.b., measurements with methods not included in the ranking will not be included in the inventory.
361
+
362
+ Returns
363
+ -------
364
+ Callable[[dict], tuple[bool, str, dict, dict]]
365
+ The customised `should_run` function with the signature
366
+ `(cycle: dict) -> (should_run_: bool, cycle_id: str, inventory: dict, logs: dict)`.
367
+ """
368
+
369
+ def should_run(cycle: dict) -> tuple[bool, str, dict, dict]:
370
+ """
371
+ Determine if calculations should run for a given [Cycle](https://www.hestia.earth/schema/Cycle) based on
372
+ available carbon stock data. If data availability is sufficient, return an inventory of pre-processed input
373
+ data for the model and log data.
374
+
375
+ Parameters
376
+ ----------
377
+ cycle : dict
378
+ The cycle dictionary for which the calculations will be evaluated.
379
+
380
+ Returns
381
+ -------
382
+ tuple[bool, str, dict, dict]
383
+ `(should_run, cycle_id, inventory, logs)`
384
+ """
385
+ cycle_id = cycle.get("@id")
386
+ site = _get_site(cycle)
387
+ cycles = related_cycles(site)
388
+
389
+ carbon_stock_measurements = [
390
+ node for node in site.get("measurements", []) if all([
391
+ node_term_match(node, carbon_stock_term_id),
392
+ _has_valid_array_fields(node),
393
+ _has_valid_dates(node),
394
+ node.get("methodClassification") in (m.value for m in measurement_method_ranking),
395
+ should_run_measurement_func(node)
396
+ ])
397
+ ]
398
+
399
+ seed = gen_seed(site) # All cycles linked to the same site should be consistent
400
+ rng = random.default_rng(seed)
401
+
402
+ should_compile_inventory, should_compile_logs = should_compile_inventory_func(
403
+ site, cycles, carbon_stock_measurements
404
+ )
405
+
406
+ inventory, inventory_logs = (
407
+ _compile_inventory(
408
+ cycle_id,
409
+ cycles,
410
+ carbon_stock_measurements,
411
+ iterations=_ITERATIONS,
412
+ seed=rng,
413
+ measurement_method_ranking=measurement_method_ranking
414
+ ) if should_compile_inventory else ({}, {})
415
+ )
416
+
417
+ has_valid_inventory = len(inventory) > 0
418
+ has_consecutive_years = check_consecutive(inventory.keys())
419
+
420
+ should_run_ = all([has_valid_inventory, has_consecutive_years])
421
+
422
+ logs = should_compile_logs | inventory_logs | {
423
+ "seed": seed,
424
+ "has_valid_inventory": has_valid_inventory,
425
+ "has_consecutive_years": has_consecutive_years
426
+ }
427
+
428
+ return should_run_, cycle_id, inventory, logs
429
+
430
+ return should_run
431
+
432
+
433
+ def _has_valid_array_fields(node: dict) -> bool:
434
+ """Validate that the array-type fields of a node (`value`, `dates`, `sd`) have data and matching lengths."""
435
+ value = node.get("value", [])
436
+ sd = node.get("sd", [])
437
+ dates = node.get("dates", [])
438
+ return all([
439
+ len(value) > 0,
440
+ len(value) == len(dates),
441
+ len(sd) == 0 or len(sd) == len(value)
442
+ ])
443
+
444
+
445
+ def _has_valid_dates(node: dict) -> bool:
446
+ """Validate that all dates in a node's `dates` field have a valid format."""
447
+ return all(_get_datestr_format(datestr) in _VALID_DATE_FORMATS for datestr in node.get("dates", []))
448
+
449
+
450
+ def _get_site(cycle: dict) -> dict:
451
+ """
452
+ Get the [Site](https://www.hestia.earth/schema/Site) data from a [Cycle](https://www.hestia.earth/schema/Cycle).
453
+
454
+ Parameters
455
+ ----------
456
+ cycle : dict
457
+
458
+ Returns
459
+ -------
460
+ str
461
+ """
462
+ return cycle.get("site", {})
463
+
464
+
465
+ def _compile_inventory(
308
466
  cycle_id: str,
309
467
  cycles: list[dict],
310
468
  carbon_stock_measurements: list[dict],
311
469
  iterations: int = 10000,
312
- seed: Union[int, random.Generator, None] = None
470
+ seed: Union[int, random.Generator, None] = None,
471
+ measurement_method_ranking: list[MeasurementMethodClassification] = DEFAULT_MEASUREMENT_METHOD_RANKING
313
472
  ) -> tuple[dict, dict]:
314
473
  """
315
474
  Compile an annual inventory of carbon stocks, changes in carbon stocks, carbon stock change emissions, and the share
@@ -354,17 +513,16 @@ def compile_inventory(
354
513
  tuple[dict, dict]
355
514
  `(inventory, logs)`
356
515
  """
357
- # Process cycles and carbon stock measurements independently
358
516
  cycle_inventory = _compile_cycle_inventory(cycles)
359
517
  carbon_stock_inventory = _compile_carbon_stock_inventory(
360
518
  carbon_stock_measurements, iterations=iterations, seed=seed
361
519
  )
362
520
 
363
- # Generate logs without side-effects
364
521
  logs = _generate_logs(cycle_inventory, carbon_stock_inventory)
365
522
 
366
- # Combine the inventories functionally
367
- inventory = _squash_inventory(cycle_id, cycle_inventory, carbon_stock_inventory)
523
+ inventory = _squash_inventory(
524
+ cycle_id, cycle_inventory, carbon_stock_inventory, measurement_method_ranking=measurement_method_ranking
525
+ )
368
526
 
369
527
  return inventory, logs
370
528
 
@@ -623,7 +781,7 @@ def _interpolate_carbon_stocks(carbon_stocks: list[CarbonStock]) -> dict:
623
781
  )
624
782
 
625
783
  update = {
626
- year: {_InventoryKey.CARBON_STOCK: lerp_carbon_stocks(
784
+ year: {_InventoryKey.CARBON_STOCK: _lerp_carbon_stocks(
627
785
  start,
628
786
  end,
629
787
  f"{year}-12-31T23:59:59"
@@ -654,7 +812,7 @@ def _calculate_stock_changes(carbon_stocks_by_year: dict) -> dict:
654
812
  """
655
813
  return {
656
814
  year: {
657
- _InventoryKey.CARBON_STOCK_CHANGE: calc_carbon_stock_change(
815
+ _InventoryKey.CARBON_STOCK_CHANGE: _calc_carbon_stock_change(
658
816
  start_group[_InventoryKey.CARBON_STOCK],
659
817
  end_group[_InventoryKey.CARBON_STOCK]
660
818
  )
@@ -681,7 +839,7 @@ def _calculate_co2_emissions(carbon_stock_changes_by_year: dict) -> dict:
681
839
  """
682
840
  return {
683
841
  year: {
684
- _InventoryKey.CO2_EMISSION: calc_carbon_stock_change_emission(
842
+ _InventoryKey.CO2_EMISSION: _calc_carbon_stock_change_emission(
685
843
  group[_InventoryKey.CARBON_STOCK_CHANGE]
686
844
  )
687
845
  } for year, group in carbon_stock_changes_by_year.items()
@@ -711,7 +869,12 @@ def _sorted_merge(*sources: Union[dict, list[dict]]) -> dict:
711
869
  return dict(sorted(merged.items()))
712
870
 
713
871
 
714
- def _squash_inventory(cycle_id: str, cycle_inventory: dict, carbon_stock_inventory: dict) -> dict:
872
+ def _squash_inventory(
873
+ cycle_id: str,
874
+ cycle_inventory: dict,
875
+ carbon_stock_inventory: dict,
876
+ measurement_method_ranking: list[MeasurementMethodClassification] = DEFAULT_MEASUREMENT_METHOD_RANKING
877
+ ) -> dict:
715
878
  """
716
879
  Combine the `cycle_inventory` and `carbon_stock_inventory` into a single inventory by merging data for each year
717
880
  using the strongest available `MeasurementMethodClassification`. Any years not relevant to the cycle identified
@@ -789,7 +952,7 @@ def _squash_inventory(cycle_id: str, cycle_inventory: dict, carbon_stock_invento
789
952
  update_dict = next(
790
953
  (
791
954
  {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)
955
+ for method in measurement_method_ranking if should_run_group(method, year)
793
956
  ),
794
957
  {}
795
958
  )
@@ -902,3 +1065,60 @@ def _format_named_tuple(value: Optional[Union[CarbonStock, CarbonStockChange, Ca
902
1065
  if isinstance(value, (CarbonStock, CarbonStockChange, CarbonStockChangeEmission))
903
1066
  else "None"
904
1067
  )
1068
+
1069
+
1070
+ def create_run_function(
1071
+ new_emission_func: Callable[[EmissionMethodTier, dict], dict]
1072
+ ) -> Callable[[str, dict], list[dict]]:
1073
+ """
1074
+ Create a run function for an emissions from carbon stock change model.
1075
+
1076
+ A model-specific `new_emission_func` should be passed as a parameter to this higher-order function to control how
1077
+ model ouputs are formatted into HESTIA emission nodes.
1078
+
1079
+ Parameters
1080
+ ----------
1081
+ new_emission_func : Callable[[EmissionMethodTier, tuple], dict]
1082
+ A function, with the signature `(method_tier: dict, **kwargs: dict) -> (emission_node: dict)`.
1083
+
1084
+ Returns
1085
+ -------
1086
+ Callable[[str, dict], list[dict]]
1087
+ The customised `run` function with the signature `(cycle_id: str, inventory: dict) -> emissions: list[dict]`.
1088
+ """
1089
+ def run(cycle_id: str, inventory: dict) -> list[dict]:
1090
+ """
1091
+ Calculate emissions for a specific cycle using from a carbon stock change using pre-compiled inventory data.
1092
+
1093
+ The emission method tier is based on the minimum measurement method tier of the carbon stock measures used to
1094
+ calculate the emission.
1095
+
1096
+ Parameters
1097
+ ----------
1098
+ cycle_id : str
1099
+ The "@id" field of the [Cycle node](https://www.hestia.earth/schema/Cycle).
1100
+ grouped_data : dict
1101
+ A dictionary containing grouped carbon stock change and share of emissions data.
1102
+
1103
+ Returns
1104
+ -------
1105
+ list[dict]
1106
+ A list of [Emission nodes](https://www.hestia.earth/schema/Emission) containing model results.
1107
+ """
1108
+ rescaled_emissions = [
1109
+ _rescale_carbon_stock_change_emission(
1110
+ group[_InventoryKey.CO2_EMISSION], group[_InventoryKey.SHARE_OF_EMISSION][cycle_id]
1111
+ ) for group in inventory.values()
1112
+ ]
1113
+ total_emission = reduce(_add_carbon_stock_change_emissions, rescaled_emissions)
1114
+
1115
+ descriptive_stats = calc_descriptive_stats(
1116
+ total_emission.value,
1117
+ EmissionStatsDefinition.SIMULATED,
1118
+ decimals=6
1119
+ )
1120
+
1121
+ method_tier = total_emission.method
1122
+ return [new_emission_func(method_tier=method_tier, **descriptive_stats)]
1123
+
1124
+ return run