hestia-earth-models 0.75.1__py3-none-any.whl → 0.75.3__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 (42) hide show
  1. hestia_earth/models/config/Cycle.json +183 -16
  2. hestia_earth/models/cycle/product/economicValueShare.py +4 -4
  3. hestia_earth/models/emepEea2019/blackCarbonToAirFuelCombustion.py +33 -0
  4. hestia_earth/models/emepEea2019/ch4ToAirFuelCombustion.py +33 -0
  5. hestia_earth/models/emepEea2019/coToAirFuelCombustion.py +33 -0
  6. hestia_earth/models/emepEea2019/nmvocToAirFuelCombustion.py +33 -0
  7. hestia_earth/models/emepEea2019/pm10ToAirFuelCombustion.py +33 -0
  8. hestia_earth/models/emepEea2019/pm25ToAirFuelCombustion.py +33 -0
  9. hestia_earth/models/emepEea2019/tspToAirFuelCombustion.py +33 -0
  10. hestia_earth/models/faostat2018/seed.py +9 -8
  11. hestia_earth/models/geospatialDatabase/histosol.py +31 -11
  12. hestia_earth/models/hestia/aboveGroundCropResidueTotal.py +2 -2
  13. hestia_earth/models/hestia/management.py +5 -4
  14. hestia_earth/models/hestia/soilClassification.py +31 -13
  15. hestia_earth/models/ipcc2019/animal/pastureGrass.py +19 -11
  16. hestia_earth/models/ipcc2019/burning_utils.py +406 -4
  17. hestia_earth/models/ipcc2019/ch4ToAirExcreta.py +26 -8
  18. hestia_earth/models/ipcc2019/ch4ToAirOrganicSoilCultivation.py +8 -11
  19. hestia_earth/models/ipcc2019/co2ToAirOrganicSoilCultivation.py +9 -12
  20. hestia_earth/models/ipcc2019/emissionsToAirOrganicSoilBurning.py +516 -0
  21. hestia_earth/models/ipcc2019/n2OToAirOrganicSoilCultivationDirect.py +10 -13
  22. hestia_earth/models/ipcc2019/nonCo2EmissionsToAirNaturalVegetationBurning.py +56 -433
  23. hestia_earth/models/ipcc2019/organicSoilCultivation_utils.py +2 -2
  24. hestia_earth/models/ipcc2019/pastureGrass.py +19 -11
  25. hestia_earth/models/ipcc2019/pastureGrass_utils.py +17 -10
  26. hestia_earth/models/linkedImpactAssessment/emissions.py +1 -1
  27. hestia_earth/models/mocking/search-results.json +1 -1
  28. hestia_earth/models/pefcrGuidanceDocument2017/__init__.py +13 -0
  29. hestia_earth/models/pefcrGuidanceDocument2017/pesticideToAirPesticideApplication.py +29 -0
  30. hestia_earth/models/pefcrGuidanceDocument2017/pesticideToSoilPesticideApplication.py +29 -0
  31. hestia_earth/models/pefcrGuidanceDocument2017/pesticideToWaterPesticideApplication.py +29 -0
  32. hestia_earth/models/pefcrGuidanceDocument2017/utils.py +55 -0
  33. hestia_earth/models/pooreNemecek2018/saplingsDepreciatedAmountPerCycle.py +1 -1
  34. hestia_earth/models/utils/blank_node.py +68 -0
  35. hestia_earth/models/utils/impact_assessment.py +3 -0
  36. hestia_earth/models/version.py +1 -1
  37. hestia_earth/orchestrator/strategies/merge/merge_node.py +32 -2
  38. {hestia_earth_models-0.75.1.dist-info → hestia_earth_models-0.75.3.dist-info}/METADATA +1 -1
  39. {hestia_earth_models-0.75.1.dist-info → hestia_earth_models-0.75.3.dist-info}/RECORD +42 -29
  40. {hestia_earth_models-0.75.1.dist-info → hestia_earth_models-0.75.3.dist-info}/WHEEL +0 -0
  41. {hestia_earth_models-0.75.1.dist-info → hestia_earth_models-0.75.3.dist-info}/licenses/LICENSE +0 -0
  42. {hestia_earth_models-0.75.1.dist-info → hestia_earth_models-0.75.3.dist-info}/top_level.txt +0 -0
@@ -19,16 +19,18 @@ from . import MODEL
19
19
  REQUIREMENTS = {
20
20
  "Site": {
21
21
  "optional": {
22
- "measurements": [{
23
- "@type": "Measurement",
24
- "value": "",
25
- "depthUpper": "",
26
- "depthLower": "",
27
- "term.termType": "soilType",
28
- "optional": {
29
- "dates": ""
22
+ "measurements": [
23
+ {
24
+ "@type": "Measurement",
25
+ "value": "",
26
+ "depthUpper": "",
27
+ "depthLower": "",
28
+ "term.termType": ["soilType", "usdaSoilType"],
29
+ "optional": {
30
+ "dates": ""
31
+ }
30
32
  }
31
- }]
33
+ ]
32
34
  }
33
35
  }
34
36
  }
@@ -41,7 +43,8 @@ RETURNS = {
41
43
  }]
42
44
  }
43
45
  LOOKUPS = {
44
- "soilType": "IPCC_SOIL_CATEGORY"
46
+ "soilType": "IPCC_SOIL_CATEGORY",
47
+ "usdaSoilType": "IPCC_SOIL_CATEGORY"
45
48
  }
46
49
  TERM_ID = 'organicSoils,mineralSoils'
47
50
 
@@ -50,6 +53,10 @@ ORGANIC_SOILS_TERM_ID = MEASUREMENT_TERM_IDS[0]
50
53
  MINERAL_SOILS_TERM_ID = MEASUREMENT_TERM_IDS[1]
51
54
  METHOD = MeasurementMethodClassification.MODELLED_USING_OTHER_MEASUREMENTS.value
52
55
 
56
+ _INPUT_TERM_TYPES = (
57
+ TermTermType.SOILTYPE,
58
+ TermTermType.USDASOILTYPE
59
+ )
53
60
  TARGET_LOOKUP_VALUE = IPCC_SOIL_CATEGORY_TO_SOIL_TYPE_LOOKUP_VALUE[IpccSoilCategory.ORGANIC_SOILS]
54
61
 
55
62
  IS_100_THRESHOLD = 99.5
@@ -65,6 +72,7 @@ def _measurement(term_id: str, **kwargs):
65
72
 
66
73
  class _SoilTypeDatum(NamedTuple):
67
74
  term_id: str
75
+ term_type: str
68
76
  depth_upper: float
69
77
  depth_lower: float
70
78
  dates: list[str]
@@ -105,14 +113,16 @@ def _extract_soil_type_data(node: dict) -> _SoilTypeDatum:
105
113
  depth_upper = node.get("depthUpper")
106
114
  depth_lower = node.get("depthLower")
107
115
  depth_interval = (depth_upper, depth_lower)
116
+ term_type = node.get("term", {}).get("termType")
108
117
 
109
118
  return _SoilTypeDatum(
110
119
  term_id=node.get("term", {}).get("@id"),
120
+ term_type=term_type,
111
121
  depth_upper=depth_upper,
112
122
  depth_lower=depth_lower,
113
123
  dates=node.get("dates", []),
114
124
  value=get_node_value(node),
115
- is_organic=node_lookup_match(node, LOOKUPS["soilType"], TARGET_LOOKUP_VALUE),
125
+ is_organic=node_lookup_match(node, LOOKUPS[term_type], TARGET_LOOKUP_VALUE),
116
126
  is_complete_depth=all(depth is not None for depth in depth_interval),
117
127
  is_standard_depth=depth_interval in STANDARD_DEPTHS,
118
128
  )
@@ -126,7 +136,7 @@ def _classify_soil_type_data(soil_type_data: list[_SoilTypeDatum]):
126
136
 
127
137
  def classify(inventory: _SoilTypeInventory, datum: _SoilTypeDatum) -> _SoilTypeInventory:
128
138
  """
129
- Sum the values of organic and mineral `soilType` Measurements by depth interval and date.
139
+ Sum the values of organic and mineral `soilType`/`usdaSoilType` Measurements by depth interval and date.
130
140
  """
131
141
  keys = _soil_type_data_to_inventory_keys(datum)
132
142
 
@@ -252,7 +262,7 @@ def _filter_data_by_depth_availability(data: list[_SoilTypeDatum]):
252
262
 
253
263
  def _should_run(site: dict):
254
264
  soil_type_nodes = split_nodes_by_dates(
255
- filter_list_term_type(site.get("measurements", []), TermTermType.SOILTYPE)
265
+ get_soil_type_nodes(site)
256
266
  )
257
267
 
258
268
  filtered_by, soil_type_data = _filter_data_by_depth_availability(
@@ -280,6 +290,14 @@ def _should_run(site: dict):
280
290
  return should_run, inventory
281
291
 
282
292
 
293
+ def get_soil_type_nodes(site: dict) -> list[dict]:
294
+ measurements = site.get("measurements", [])
295
+ return next(
296
+ (nodes for term_type in _INPUT_TERM_TYPES if (nodes := filter_list_term_type(measurements, term_type))),
297
+ []
298
+ )
299
+
300
+
283
301
  _INVENTORY_KEY_TO_FIELD_KEY = {
284
302
  "depth_upper": "depthUpper",
285
303
  "depth_lower": "depthLower",
@@ -11,6 +11,7 @@ from hestia_earth.models.utils.property import get_node_property
11
11
  from hestia_earth.models.utils.cycle import get_animals_by_period
12
12
  from .. import MODEL
13
13
  from ..pastureGrass_utils import (
14
+ has_cycle_inputs_feed,
14
15
  practice_input_id,
15
16
  should_run_practice,
16
17
  calculate_meanDE,
@@ -79,7 +80,11 @@ REQUIREMENTS = {
79
80
  "properties": [{
80
81
  "@type": "Property",
81
82
  "value": "",
82
- "term.@id": ["neutralDetergentFibreContent", "energyContentHigherHeatingValue"]
83
+ "term.@id": [
84
+ "neutralDetergentFibreContent",
85
+ "energyContentHigherHeatingValue",
86
+ "energyDigestibilityRuminants"
87
+ ]
83
88
  }]
84
89
  }
85
90
  }],
@@ -200,6 +205,7 @@ def _run_practice(
200
205
  GE = (
201
206
  calculate_GE([values], REM, REG, NEwool, NEm_feed, NEg_feed) / (meanDE/100)
202
207
  ) if meanDE else 0
208
+ has_positive_GE_value = GE >= 0
203
209
 
204
210
  value = (GE / meanECHHV) * (list_sum(practice.get('value', [0])) / 100)
205
211
 
@@ -229,11 +235,12 @@ def _run_practice(
229
235
  logRequirements(cycle, model=MODEL, term=input_term_id, animalId=animal.get('animalId'), model_key=MODEL_KEY,
230
236
  feed_logs=log_as_table(log_feed),
231
237
  has_positive_feed_values=has_positive_feed_values,
238
+ has_positive_GE_value=has_positive_GE_value,
232
239
  animal_logs=logs,
233
240
  animal_lookups=animal_lookups,
234
241
  animal_properties=animal_properties)
235
242
 
236
- should_run = all([has_positive_feed_values])
243
+ should_run = all([has_positive_feed_values, has_positive_GE_value])
237
244
  logShouldRun(cycle, MODEL, input_term_id, should_run, animalId=animal.get('animalId'), model_key=MODEL_KEY)
238
245
 
239
246
  return _input(input_term_id, value) if should_run else None
@@ -272,10 +279,8 @@ def _should_run(cycle: dict, animals: list, practices: dict):
272
279
  freshForage_incomplete = _is_term_type_incomplete(cycle, 'freshForage')
273
280
  all_animals_have_value = all([a.get('value', 0) > 0 for a in animals])
274
281
 
275
- no_cycle_inputs_feed = all([not input.get('isAnimalFeed', False) for input in cycle.get('inputs', [])])
276
-
277
- meanDE = calculate_meanDE(practices)
278
- meanECHHV = calculate_meanECHHV(practices)
282
+ meanDE = calculate_meanDE(cycle, practices)
283
+ meanECHHV = calculate_meanECHHV(cycle, practices)
279
284
  REM = calculate_REM(meanDE)
280
285
  REG = calculate_REG(meanDE)
281
286
 
@@ -286,7 +291,6 @@ def _should_run(cycle: dict, animals: list, practices: dict):
286
291
  animalFeed_complete,
287
292
  animalPopulation_complete,
288
293
  freshForage_incomplete,
289
- no_cycle_inputs_feed,
290
294
  all_animals_have_value,
291
295
  has_practice_termType_system,
292
296
  has_practice_pastureGrass_with_landCover_key,
@@ -300,12 +304,11 @@ def _should_run(cycle: dict, animals: list, practices: dict):
300
304
  term_type_animalFeed_complete=animalFeed_complete,
301
305
  term_type_animalPopulation_complete=animalPopulation_complete,
302
306
  term_type_freshForage_incomplete=freshForage_incomplete,
303
- no_cycle_inputs_feed=no_cycle_inputs_feed,
304
307
  all_animals_have_value=all_animals_have_value,
305
308
  has_practice_termType_system=has_practice_termType_system,
306
309
  has_practice_pastureGrass_with_landCover_key=has_practice_pastureGrass_with_landCover_key,
307
- grass_MeanDE=calculate_meanDE(practices, term=term_id),
308
- grass_MeanECHHV=calculate_meanECHHV(practices, term=term_id),
310
+ grass_MeanDE=calculate_meanDE(cycle, practices, term=term_id),
311
+ grass_MeanECHHV=calculate_meanECHHV(cycle, practices, term=term_id),
309
312
  grass_REM=REM,
310
313
  grass_REG=REG)
311
314
 
@@ -314,8 +317,13 @@ def _should_run(cycle: dict, animals: list, practices: dict):
314
317
  return should_run, meanDE, meanECHHV, REM, REG, systems
315
318
 
316
319
 
317
- def run(cycle: dict):
320
+ def _run(cycle: dict):
318
321
  animals = get_animals_by_period(cycle)
319
322
  practices = list(filter(should_run_practice(cycle), cycle.get('practices', [])))
320
323
  should_run, meanDE, meanECHHV, REM, REG, systems = _should_run(cycle, animals, practices)
321
324
  return list(map(_run_animal(cycle, meanDE, meanECHHV, REM, REG, systems, practices), animals)) if should_run else []
325
+
326
+
327
+ def run(cycle: dict):
328
+ # determines if this model or animal model should run
329
+ return _run(cycle) if not has_cycle_inputs_feed(cycle) else []
@@ -1,4 +1,46 @@
1
1
  from enum import Enum
2
+ from functools import reduce
3
+ from itertools import product
4
+ import numpy as np
5
+ import numpy.typing as npt
6
+ from typing import Any, Callable, Literal, NotRequired, Optional, TypedDict, Union
7
+ from hestia_earth.schema import EmissionMethodTier, EmissionStatsDefinition, SiteSiteType
8
+
9
+ from hestia_earth.utils.descriptive_stats import calc_descriptive_stats
10
+ from hestia_earth.utils.lookup import download_lookup, get_table_value, column_name
11
+ from hestia_earth.utils.tools import safe_parse_float
12
+ from hestia_earth.utils.stats import truncated_normal_1d
13
+
14
+ from hestia_earth.models.log import (
15
+ debugMissingLookup, format_bool, format_decimal_percentage, format_float, format_nd_array, format_str, log_as_table,
16
+ logRequirements, logShouldRun
17
+ )
18
+ from hestia_earth.models.utils.ecoClimateZone import EcoClimateZone
19
+ from hestia_earth.models.utils.lookup import get_region_lookup_value
20
+
21
+ from . import MODEL
22
+ from .biomass_utils import BiomassCategory
23
+
24
+ _LOOKUPS = {
25
+ "region-percentageAreaBurnedDuringForestClearance": "percentage_area_burned_during_forest_clearance"
26
+ }
27
+
28
+ ITERATIONS = 10000 # N interations for which the model will run as a Monte Carlo simulation
29
+ TIER = EmissionMethodTier.TIER_1.value
30
+ DEFAULT_FACTOR = {"value": 0}
31
+ _DEFAULT_PERCENT_BURNED = 0
32
+
33
+ _STATS_DEFINITION = EmissionStatsDefinition.SIMULATED.value
34
+
35
+ AMORTISATION_PERIOD = 20 # Emissions should be amortised over 20 years
36
+
37
+ EXCLUDED_ECO_CLIMATE_ZONES = {EcoClimateZone.POLAR_MOIST, EcoClimateZone.POLAR_DRY}
38
+ EXCLUDED_SITE_TYPES = {SiteSiteType.GLASS_OR_HIGH_ACCESSIBLE_COVER.value}
39
+ NATURAL_VEGETATION_CATEGORIES = {
40
+ BiomassCategory.FOREST,
41
+ BiomassCategory.NATURAL_FOREST,
42
+ BiomassCategory.PLANTATION_FOREST
43
+ }
2
44
 
3
45
 
4
46
  class FuelCategory(Enum):
@@ -6,11 +48,10 @@ class FuelCategory(Enum):
6
48
  Natural vegetation fuel categories from IPCC (2019).
7
49
  """
8
50
  BOREAL_FOREST = "boreal-forest"
9
- DRAINED_EXTRATROPICAL_ORGANIC_SOILS_WILDFIRE = "drained-extratropical-organic-soils-wildfire"
51
+ DRAINED_EXTRATROPICAL_ORGANIC_SOILS_WILDFIRE = "drained-extratropical-organic-soils-wildfire" # boreal/temperate
10
52
  DRAINED_TROPICAL_ORGANIC_SOILS_WILDFIRE = "drained-tropical-organic-soils-wildfire"
11
53
  EUCALYPT_FOREST = "eucalypt-forest"
12
54
  NATURAL_TROPICAL_FOREST = "natural-tropical-forest" # mean of primary and secondary tropical forest
13
- PEATLAND_VEGETATION = "peatland-vegetation"
14
55
  PRIMARY_TROPICAL_FOREST = "primary-tropical-forest"
15
56
  SAVANNA_GRASSLAND_EARLY_DRY_SEASON_BURNS = "savanna-grassland-early-dry-season-burns"
16
57
  SAVANNA_GRASSLAND_MID_TO_LATE_DRY_SEASON_BURNS = "savanna-grassland-mid-to-late-dry-season-burns"
@@ -21,17 +62,378 @@ class FuelCategory(Enum):
21
62
  TEMPERATE_FOREST = "temperate-forest"
22
63
  TERTIARY_TROPICAL_FOREST = "tertiary-tropical-forest"
23
64
  TROPICAL_ORGANIC_SOILS_PRESCRIBED_FIRE = "tropical-organic-soils-prescribed-fire"
24
- TUNDRA = "tundra"
25
65
  UNDRAINED_EXTRATROPICAL_ORGANIC_SOILS_WILDFIRE = "undrained-extratropical-organic-soils-wildfire"
26
66
  UNKNOWN_TROPICAL_FOREST = "unknown-tropical-forest" # mean of primary, secondary and tertiary tropical forest
27
67
 
28
68
 
29
69
  class EmissionCategory(Enum):
30
70
  """
31
- Natural vegetation burning emission categories from IPCC (2019).
71
+ Natural vegetation and organic soil burning emission categories from IPCC (2019).
32
72
  """
33
73
  AGRICULTURAL_RESIDUES = "agricultural-residues"
34
74
  BIOFUEL_BURNING = "biofuel-burning"
75
+ EXTRATROPICAL_ORGANIC_SOILS = "extratropical-organic-soils"
35
76
  OTHER_FOREST = "other-forest"
36
77
  SAVANNA_AND_GRASSLAND = "savanna-and-grassland"
37
78
  TROPICAL_FOREST = "tropical-forest"
79
+ TROPICAL_ORGANIC_SOILS = "tropical-organic-soils"
80
+
81
+
82
+ class InventoryYear(TypedDict, total=False):
83
+ biomass_category_summary: dict[BiomassCategory, float]
84
+ natural_vegetation_delta: dict[BiomassCategory, float]
85
+ fuel_burnt_per_category: dict[FuelCategory, npt.NDArray]
86
+ annual_emissions: dict[str, npt.NDArray]
87
+ amortised_emissions: dict[str, npt.NDArray]
88
+ share_of_emissions: dict[str, float] # {cycle_id (str): value, ...}
89
+ allocated_emissions: dict[str, dict[str, npt.NDArray]]
90
+ percent_organic_soils: NotRequired[float]
91
+
92
+
93
+ InventoryKey = Literal[
94
+ "biomass_category_summary",
95
+ "natural_vegetation_delta",
96
+ "fuel_burnt_per_category",
97
+ "annual_emissions",
98
+ "amortised_emissions",
99
+ "share_of_emissions",
100
+ "allocated_emissions",
101
+ "percent_organic_soils"
102
+ ]
103
+
104
+ Inventory = dict[int, InventoryYear]
105
+ """
106
+ {year (int): data (_InventoryYear)}
107
+ """
108
+
109
+
110
+ _FUEL_CATEGORY_TO_EMISSION_CATEGORY = {
111
+ FuelCategory.BOREAL_FOREST: EmissionCategory.OTHER_FOREST,
112
+ FuelCategory.EUCALYPT_FOREST: EmissionCategory.OTHER_FOREST,
113
+ FuelCategory.NATURAL_TROPICAL_FOREST: EmissionCategory.TROPICAL_FOREST,
114
+ FuelCategory.PRIMARY_TROPICAL_FOREST: EmissionCategory.TROPICAL_FOREST,
115
+ FuelCategory.SAVANNA_GRASSLAND_EARLY_DRY_SEASON_BURNS: EmissionCategory.SAVANNA_AND_GRASSLAND,
116
+ FuelCategory.SAVANNA_GRASSLAND_MID_TO_LATE_DRY_SEASON_BURNS: EmissionCategory.SAVANNA_AND_GRASSLAND,
117
+ FuelCategory.SAVANNA_WOODLAND_EARLY_DRY_SEASON_BURNS: EmissionCategory.SAVANNA_AND_GRASSLAND,
118
+ FuelCategory.SAVANNA_WOODLAND_MID_TO_LATE_DRY_SEASON_BURNS: EmissionCategory.SAVANNA_AND_GRASSLAND,
119
+ FuelCategory.SECONDARY_TROPICAL_FOREST: EmissionCategory.TROPICAL_FOREST,
120
+ FuelCategory.SHRUBLAND: EmissionCategory.SAVANNA_AND_GRASSLAND,
121
+ FuelCategory.TEMPERATE_FOREST: EmissionCategory.OTHER_FOREST,
122
+ FuelCategory.TERTIARY_TROPICAL_FOREST: EmissionCategory.TROPICAL_FOREST,
123
+ FuelCategory.UNKNOWN_TROPICAL_FOREST: EmissionCategory.TROPICAL_FOREST,
124
+ FuelCategory.DRAINED_EXTRATROPICAL_ORGANIC_SOILS_WILDFIRE: EmissionCategory.EXTRATROPICAL_ORGANIC_SOILS,
125
+ FuelCategory.DRAINED_TROPICAL_ORGANIC_SOILS_WILDFIRE: EmissionCategory.TROPICAL_ORGANIC_SOILS,
126
+ FuelCategory.TROPICAL_ORGANIC_SOILS_PRESCRIBED_FIRE: EmissionCategory.TROPICAL_ORGANIC_SOILS,
127
+ FuelCategory.UNDRAINED_EXTRATROPICAL_ORGANIC_SOILS_WILDFIRE: EmissionCategory.EXTRATROPICAL_ORGANIC_SOILS
128
+ }
129
+ """
130
+ Mapping from natural vegetation and organic soil fuel category to emission category.
131
+ """
132
+
133
+
134
+ def get_emission_category(fuel_category: FuelCategory) -> EmissionCategory:
135
+ """
136
+ Get the IPCC (2019) emission category that corresponds to a fuel category.
137
+ """
138
+ return _FUEL_CATEGORY_TO_EMISSION_CATEGORY.get(fuel_category)
139
+
140
+
141
+ def _sample_truncated_normal(
142
+ *, iterations: int, value: float, sd: float, seed: Union[int, np.random.Generator, None] = None, **_
143
+ ) -> npt.NDArray:
144
+ """
145
+ Randomly sample a model parameter with a truncated normal distribution. Neither fuel factors nor emission factors
146
+ can be below 0, so truncated normal sampling used.
147
+ """
148
+ return truncated_normal_1d(shape=(1, iterations), mu=value, sigma=sd, low=0, high=np.inf, seed=seed)
149
+
150
+
151
+ def _sample_constant(*, value: float, **_) -> npt.NDArray:
152
+ """Sample a constant model parameter."""
153
+ return np.array(value)
154
+
155
+
156
+ _KWARGS_TO_SAMPLE_FUNC = {
157
+ # ("value", "se", "n"): _sample_standard_error_normal,
158
+ ("value", "sd"): _sample_truncated_normal,
159
+ ("value",): _sample_constant
160
+ }
161
+ """
162
+ Mapping from available distribution data to sample function.
163
+ """
164
+
165
+
166
+ def get_sample_func(kwargs: dict) -> Callable:
167
+ """
168
+ Select the correct sample function for a parameter based on the distribution data available. All possible
169
+ parameters for the model should have, at a minimum, a `value`, meaning that no default function needs to be
170
+ specified.
171
+
172
+ This function has been extracted into it's own method to allow for mocking of sample function.
173
+
174
+ Keyword Args
175
+ ------------
176
+ value : float
177
+ The distribution mean.
178
+ sd : float
179
+ The standard deviation of the distribution.
180
+ se : float
181
+ The standard error of the distribution.
182
+ n : float
183
+ Sample size.
184
+
185
+ Returns
186
+ -------
187
+ Callable
188
+ The sample function for the distribution.
189
+ """
190
+ return next(
191
+ sample_func for required_kwargs, sample_func in _KWARGS_TO_SAMPLE_FUNC.items()
192
+ if all(kwarg in kwargs.keys() for kwarg in required_kwargs)
193
+ )
194
+
195
+
196
+ def _get_fuel_factor(fuel_category: FuelCategory, emission_term_ids: list[str]) -> dict:
197
+ """
198
+ Retrieve distribution data for a specific fuel category.
199
+ """
200
+ LOOKUP_KEY = "ipcc2019FuelCategory_tonnesDryMatterCombustedPerHaBurned"
201
+ LOOKUP_FILENAME = f"{LOOKUP_KEY}.csv"
202
+ TARGET_DATA = (
203
+ "value",
204
+ # "se", # useless without n data
205
+ # "n" # TODO: add n data to lookup
206
+ )
207
+
208
+ row = fuel_category.name
209
+
210
+ lookup = download_lookup(LOOKUP_FILENAME)
211
+
212
+ data = {
213
+ target: get_table_value(lookup, column_name("FuelCategory"), row, column_name(target))
214
+ for target in TARGET_DATA
215
+ }
216
+
217
+ for term_id, target in product(emission_term_ids, TARGET_DATA):
218
+ debugMissingLookup(LOOKUP_FILENAME, "FuelCategory", row, target, data.get(target), model=MODEL, term=term_id)
219
+
220
+ return (
221
+ {
222
+ k: parsed for k, v in data.items() if (parsed := safe_parse_float(v, default=None)) is not None
223
+ } # remove missing
224
+ or DEFAULT_FACTOR # if parsed dict empty, return default
225
+ )
226
+
227
+
228
+ def sample_fuel_factor(
229
+ fuel_category: FuelCategory,
230
+ emission_term_ids: list[str],
231
+ *,
232
+ seed: Union[int, np.random.Generator, None] = None
233
+ ) -> npt.NDArray:
234
+ """
235
+ Generate random samples from a fuel factor's distribution data.
236
+ """
237
+ factor_data = _get_fuel_factor(fuel_category, emission_term_ids)
238
+ sample_func = get_sample_func(factor_data)
239
+ return sample_func(iterations=ITERATIONS, seed=seed, **factor_data)
240
+
241
+
242
+ def get_percent_burned(site: str):
243
+ LOOKUP_KEY = "region-percentageAreaBurnedDuringForestClearance"
244
+ LOOKUP_FILENAME = f"{LOOKUP_KEY}.csv"
245
+ country_id = site.get("country", {}).get("@id")
246
+
247
+ value = get_region_lookup_value(LOOKUP_FILENAME, country_id, _LOOKUPS[LOOKUP_KEY])
248
+ return safe_parse_float(value, _DEFAULT_PERCENT_BURNED)
249
+
250
+
251
+ def _sum_cycle_emissions(term_id: str, cycle_id: str, inventory: Inventory) -> npt.NDArray:
252
+ """
253
+ Sum the emissions allocated to a cycle.
254
+ """
255
+ KEY = "allocated_emissions"
256
+
257
+ def add_cycle_emissions(result: npt.NDArray, year: int) -> npt.NDArray:
258
+ allocated_emissions = inventory.get(year, {}).get(KEY, {}).get(term_id, {})
259
+ return result + allocated_emissions.get(cycle_id, np.array(0))
260
+
261
+ return reduce(add_cycle_emissions, inventory.keys(), np.array(0))
262
+
263
+
264
+ def calc_emission(fuel_burnt: npt.NDArray, emission_factor: npt.NDArray, conversion_factor: float = 1) -> npt.NDArray:
265
+ """
266
+ Calculate the emission from a fuel burning.
267
+
268
+ Parameters
269
+ ----------
270
+ fuel_burnt : NDArray
271
+ The mass of burnt fuel (kg).
272
+ emission_factor : NDArray
273
+ Emission conversion factor (kg emission per kg of fuel burnt).
274
+ conversion_factor : float, optional
275
+ Optional factor to convert emission factor to other units (e.g., from CO2-C to CO2).
276
+
277
+ Returns
278
+ -------
279
+ NDArray
280
+ The mass of emission (kg)
281
+ """
282
+ return fuel_burnt * emission_factor * conversion_factor
283
+
284
+
285
+ def run_emission(term_id: str, cycle_id: str, inventory: Inventory) -> list[dict]:
286
+ """
287
+ Retrieve the sum relevant emissions and format them as a HESTIA
288
+ [Emission node](https://www.hestia.earth/schema/Emission).
289
+ """
290
+ emission = _sum_cycle_emissions(term_id, cycle_id, inventory)
291
+ kwargs = (
292
+ calc_descriptive_stats(emission, _STATS_DEFINITION) if emission.size > 1
293
+ else {"value": [emission]}
294
+ )
295
+ return term_id, kwargs
296
+
297
+
298
+ def _format_column_header(*keys: tuple[Union[Enum, str], ...]) -> str:
299
+ """Format a variable number of enums and strings for logging as a table column header."""
300
+ return " ".join(format_str(k.value if isinstance(k, Enum) else format_str(k)) for k in keys)
301
+
302
+
303
+ def _format_eco_climate_zone(value: EcoClimateZone) -> str:
304
+ """Format an eco-climate zone for logging."""
305
+ return (
306
+ format_str(str(value.name).lower().replace("_", " ").capitalize()) if isinstance(value, EcoClimateZone)
307
+ else "None"
308
+ )
309
+
310
+
311
+ _LOGS_FORMAT_DATA: dict[str, Callable] = {
312
+ "has_valid_site_type": format_bool,
313
+ "eco_climate_zone": _format_eco_climate_zone,
314
+ "has_valid_eco_climate_zone": format_bool,
315
+ "has_land_cover_nodes": format_bool,
316
+ "should_compile_inventory": format_bool,
317
+ "percent_burned": lambda x: format_float(x, "pct"),
318
+ }
319
+ _DEFAULT_FORMAT_FUNC = format_str
320
+
321
+
322
+ def _format_logs(logs: dict) -> dict[str, str]:
323
+ """
324
+ Format model logs - excluding the inventory data, which must be formatted separately.
325
+ """
326
+ return {key: _LOGS_FORMAT_DATA.get(key, _DEFAULT_FORMAT_FUNC)(value) for key, value in logs.items()}
327
+
328
+
329
+ _INVENTORY_FORMAT_DATA: dict[InventoryKey, dict[Literal["filter_by", "format_func"], Any]] = {
330
+ "fuel_burnt_per_category": {
331
+ "format_func": lambda x: format_nd_array(x, "kg")
332
+ },
333
+ "annual_emissions": {
334
+ "filter_by": ("term_id", ),
335
+ "format_func": lambda x: format_nd_array(x, "kg")
336
+ },
337
+ "amortised_emissions": {
338
+ "filter_by": ("term_id", ),
339
+ "format_func": lambda x: format_nd_array(x, "kg")
340
+ },
341
+ "share_of_emissions": {
342
+ "filter_by": ("cycle_id", ),
343
+ "format_func": format_decimal_percentage
344
+ },
345
+ "allocated_emissions": {
346
+ "filter_by": ("term_id", "cycle_id"),
347
+ "format_func": lambda x: format_nd_array(x, "kg")
348
+ },
349
+ "percent_organic_soils": {
350
+ "format_func": lambda x: format_float(x, "pct")
351
+ }
352
+ }
353
+ """
354
+ Mapping between inventory key and formatting options for logging in a table. Inventory keys not included in the dict
355
+ will not be logged in the table.
356
+ """
357
+
358
+
359
+ def _flatten_dict(nested_dict: dict) -> dict[tuple, Any]:
360
+ """
361
+ Flatten a nested dict, returns dict with keys as tuples with format `(key_level_1, key_level_2, ..., key_level_n)`.
362
+ """
363
+ def flatten(current: dict, path: tuple = ()):
364
+
365
+ if isinstance(current, dict):
366
+ for key, value in current.items():
367
+ yield from flatten(value, path + (key,))
368
+ else:
369
+ yield (path, current)
370
+
371
+ return dict(flatten(nested_dict))
372
+
373
+
374
+ def _get_relevant_inner_keys(
375
+ term_id: str,
376
+ cycle_id: str,
377
+ key: str,
378
+ inventory: Inventory,
379
+ *,
380
+ filter_by: Optional[tuple[Literal["term_id", "cycle_id"], ...]] = None,
381
+ **_
382
+ ) -> list[tuple]:
383
+ """
384
+ Get the column headings for the formatted table. Nested inventory values should be flattened, with nested keys
385
+ being transformed into a tuple with the format `(key_level_1, key_level_2, ..., key_level_n)`.
386
+
387
+ Inner keys not relevant to the emission term being logged or the cycle the model is running on should be excluded.
388
+ """
389
+ FILTER_VALUES = {"term_id": term_id, "cycle_id": cycle_id}
390
+ filter_target = (
391
+ tuple(val for f in filter_by if (val := FILTER_VALUES.get(f)))
392
+ if filter_by else None
393
+ )
394
+
395
+ inner_keys = {
396
+ tuple(k) for inner in inventory.values() for k in _flatten_dict(inner.get(key, {}))
397
+ if not filter_target or k == filter_target
398
+ }
399
+
400
+ return sorted(
401
+ inner_keys,
402
+ key=lambda category: category.value if isinstance(category, Enum) else str(category)
403
+ )
404
+
405
+
406
+ def _format_inventory(term_id: str, cycle_id: str, inventory: dict) -> str:
407
+ """
408
+ Format the inventory for logging as a table.
409
+
410
+ Extract relevant data, flatten nested dicts and format inventory values based on expected data type.
411
+ """
412
+ relevant_inventory_keys = {
413
+ inventory_key: _get_relevant_inner_keys(term_id, cycle_id, inventory_key, inventory, **kwargs)
414
+ for inventory_key, kwargs in _INVENTORY_FORMAT_DATA.items()
415
+ }
416
+
417
+ return log_as_table(
418
+ {
419
+ "year": year,
420
+ **{
421
+ _format_column_header(inventory_key, *inner_key): _INVENTORY_FORMAT_DATA[inventory_key]["format_func"](
422
+ reduce(lambda d, k: d.get(k, {}), [year, inventory_key, *inner_key], inventory)
423
+ )
424
+ for inventory_key, relevant_inner_keys in relevant_inventory_keys.items()
425
+ for inner_key in relevant_inner_keys
426
+ }
427
+ } for year in inventory
428
+ ) if inventory else "None"
429
+
430
+
431
+ def log_emission_data(should_run: bool, term_id: str, cycle: dict, inventory: dict, logs: dict):
432
+ """
433
+ Format and log the model logs and inventory.
434
+ """
435
+ formatted_logs = _format_logs(logs)
436
+ formatted_inventory = _format_inventory(term_id, cycle.get("@id"), inventory)
437
+
438
+ logRequirements(cycle, model=MODEL, term=term_id, **formatted_logs, inventory=formatted_inventory)
439
+ logShouldRun(cycle, MODEL, term_id, should_run, methodTier=TIER)