hestia-earth-models 0.64.8__py3-none-any.whl → 0.64.10__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 (105) hide show
  1. hestia_earth/models/cml2001Baseline/abioticResourceDepletionFossilFuels.py +175 -0
  2. hestia_earth/models/cml2001Baseline/abioticResourceDepletionMineralsAndMetals.py +136 -0
  3. hestia_earth/models/cycle/siteArea.py +2 -1
  4. hestia_earth/models/environmentalFootprintV3/soilQualityIndexLandOccupation.py +73 -82
  5. hestia_earth/models/environmentalFootprintV3/soilQualityIndexLandTransformation.py +102 -116
  6. hestia_earth/models/environmentalFootprintV3/soilQualityIndexTotalLandUseEffects.py +27 -16
  7. hestia_earth/models/faostat2018/landTransformationFromCropland100YearAverage.py +3 -2
  8. hestia_earth/models/faostat2018/landTransformationFromCropland20YearAverage.py +3 -2
  9. hestia_earth/models/frischknechtEtAl2000/ionisingRadiationKbqU235Eq.py +69 -37
  10. hestia_earth/models/ipcc2019/aboveGroundBiomass.py +31 -243
  11. hestia_earth/models/ipcc2019/animal/fatContent.py +38 -0
  12. hestia_earth/models/ipcc2019/animal/liveweightGain.py +3 -54
  13. hestia_earth/models/ipcc2019/animal/liveweightPerHead.py +3 -54
  14. hestia_earth/models/ipcc2019/animal/pregnancyRateTotal.py +38 -0
  15. hestia_earth/models/ipcc2019/animal/trueProteinContent.py +38 -0
  16. hestia_earth/models/ipcc2019/animal/utils.py +87 -3
  17. hestia_earth/models/ipcc2019/animal/weightAtMaturity.py +4 -10
  18. hestia_earth/models/ipcc2019/belowGroundBiomass.py +529 -0
  19. hestia_earth/models/ipcc2019/biomass_utils.py +406 -0
  20. hestia_earth/models/ipcc2019/{co2ToAirAboveGroundBiomassStockChangeLandUseChange.py → co2ToAirAboveGroundBiomassStockChange.py} +19 -7
  21. hestia_earth/models/ipcc2019/{co2ToAirBelowGroundBiomassStockChangeLandUseChange.py → co2ToAirBelowGroundBiomassStockChange.py} +19 -7
  22. hestia_earth/models/ipcc2019/co2ToAirCarbonStockChange_utils.py +402 -73
  23. hestia_earth/models/ipcc2019/{co2ToAirSoilOrganicCarbonStockChangeManagementChange.py → co2ToAirSoilOrganicCarbonStockChange.py} +20 -8
  24. hestia_earth/models/ipcc2019/organicCarbonPerHa.py +3 -1
  25. hestia_earth/models/ipcc2019/pastureGrass_utils.py +6 -7
  26. hestia_earth/models/lcImpactAllEffects100Years/damageToFreshwaterEcosystemsFreshwaterEutrophication.py +2 -2
  27. hestia_earth/models/lcImpactAllEffects100Years/damageToFreshwaterEcosystemsWaterStress.py +2 -2
  28. hestia_earth/models/lcImpactAllEffects100Years/damageToHumanHealthParticulateMatterFormation.py +2 -2
  29. hestia_earth/models/lcImpactAllEffects100Years/damageToHumanHealthPhotochemicalOzoneFormation.py +2 -2
  30. hestia_earth/models/lcImpactAllEffects100Years/damageToHumanHealthWaterStress.py +2 -2
  31. hestia_earth/models/lcImpactAllEffects100Years/damageToMarineEcosystemsMarineEutrophication.py +2 -2
  32. hestia_earth/models/lcImpactAllEffects100Years/damageToTerrestrialEcosystemsPhotochemicalOzoneFormation.py +2 -2
  33. hestia_earth/models/lcImpactAllEffects100Years/damageToTerrestrialEcosystemsTerrestrialAcidification.py +2 -2
  34. hestia_earth/models/lcImpactAllEffectsInfinite/damageToFreshwaterEcosystemsFreshwaterEutrophication.py +2 -2
  35. hestia_earth/models/lcImpactAllEffectsInfinite/damageToFreshwaterEcosystemsWaterStress.py +2 -2
  36. hestia_earth/models/lcImpactAllEffectsInfinite/damageToHumanHealthParticulateMatterFormation.py +2 -2
  37. hestia_earth/models/lcImpactAllEffectsInfinite/damageToHumanHealthPhotochemicalOzoneFormation.py +2 -2
  38. hestia_earth/models/lcImpactAllEffectsInfinite/damageToHumanHealthWaterStress.py +2 -2
  39. hestia_earth/models/lcImpactAllEffectsInfinite/damageToMarineEcosystemsMarineEutrophication.py +2 -2
  40. hestia_earth/models/lcImpactAllEffectsInfinite/damageToTerrestrialEcosystemsPhotochemicalOzoneFormation.py +2 -2
  41. hestia_earth/models/lcImpactAllEffectsInfinite/damageToTerrestrialEcosystemsTerrestrialAcidification.py +2 -2
  42. hestia_earth/models/lcImpactCertainEffects100Years/damageToFreshwaterEcosystemsFreshwaterEutrophication.py +2 -2
  43. hestia_earth/models/lcImpactCertainEffects100Years/damageToFreshwaterEcosystemsWaterStress.py +2 -2
  44. hestia_earth/models/lcImpactCertainEffects100Years/damageToHumanHealthParticulateMatterFormation.py +2 -2
  45. hestia_earth/models/lcImpactCertainEffects100Years/damageToHumanHealthPhotochemicalOzoneFormation.py +2 -2
  46. hestia_earth/models/lcImpactCertainEffects100Years/damageToHumanHealthWaterStress.py +2 -2
  47. hestia_earth/models/lcImpactCertainEffects100Years/damageToMarineEcosystemsMarineEutrophication.py +2 -2
  48. hestia_earth/models/lcImpactCertainEffects100Years/damageToTerrestrialEcosystemsPhotochemicalOzoneFormation.py +2 -2
  49. hestia_earth/models/lcImpactCertainEffects100Years/damageToTerrestrialEcosystemsTerrestrialAcidification.py +2 -2
  50. hestia_earth/models/lcImpactCertainEffectsInfinite/damageToFreshwaterEcosystemsFreshwaterEutrophication.py +2 -2
  51. hestia_earth/models/lcImpactCertainEffectsInfinite/damageToFreshwaterEcosystemsWaterStress.py +2 -2
  52. hestia_earth/models/lcImpactCertainEffectsInfinite/damageToHumanHealthParticulateMatterFormation.py +2 -2
  53. hestia_earth/models/lcImpactCertainEffectsInfinite/damageToHumanHealthPhotochemicalOzoneFormation.py +2 -2
  54. hestia_earth/models/lcImpactCertainEffectsInfinite/damageToHumanHealthWaterStress.py +2 -2
  55. hestia_earth/models/lcImpactCertainEffectsInfinite/damageToMarineEcosystemsMarineEutrophication.py +2 -2
  56. hestia_earth/models/lcImpactCertainEffectsInfinite/damageToTerrestrialEcosystemsPhotochemicalOzoneFormation.py +2 -2
  57. hestia_earth/models/lcImpactCertainEffectsInfinite/damageToTerrestrialEcosystemsTerrestrialAcidification.py +2 -2
  58. hestia_earth/models/mocking/build_mock_search.py +44 -0
  59. hestia_earth/models/mocking/mock_search.py +8 -49
  60. hestia_earth/models/mocking/search-results.json +3078 -575
  61. hestia_earth/models/poschEtAl2008/terrestrialAcidificationPotentialAccumulatedExceedance.py +6 -3
  62. hestia_earth/models/poschEtAl2008/terrestrialEutrophicationPotentialAccumulatedExceedance.py +6 -3
  63. hestia_earth/models/preload_requests.py +1 -1
  64. hestia_earth/models/schmidt2007/utils.py +13 -4
  65. hestia_earth/models/utils/__init__.py +5 -4
  66. hestia_earth/models/utils/blank_node.py +73 -3
  67. hestia_earth/models/utils/constant.py +8 -1
  68. hestia_earth/models/utils/cycle.py +10 -13
  69. hestia_earth/models/utils/fuel.py +1 -1
  70. hestia_earth/models/utils/impact_assessment.py +39 -15
  71. hestia_earth/models/utils/lookup.py +36 -7
  72. hestia_earth/models/utils/pesticideAI.py +1 -1
  73. hestia_earth/models/utils/property.py +11 -4
  74. hestia_earth/models/utils/term.py +15 -8
  75. hestia_earth/models/version.py +1 -1
  76. {hestia_earth_models-0.64.8.dist-info → hestia_earth_models-0.64.10.dist-info}/METADATA +2 -2
  77. {hestia_earth_models-0.64.8.dist-info → hestia_earth_models-0.64.10.dist-info}/RECORD +103 -90
  78. {hestia_earth_models-0.64.8.dist-info → hestia_earth_models-0.64.10.dist-info}/WHEEL +1 -1
  79. tests/models/cml2001Baseline/test_abioticResourceDepletionFossilFuels.py +196 -0
  80. tests/models/cml2001Baseline/test_abioticResourceDepletionMineralsAndMetals.py +124 -0
  81. tests/models/edip2003/test_ozoneDepletionPotential.py +1 -13
  82. tests/models/environmentalFootprintV3/test_soilQualityIndexLandOccupation.py +97 -66
  83. tests/models/environmentalFootprintV3/test_soilQualityIndexLandTransformation.py +136 -74
  84. tests/models/environmentalFootprintV3/test_soilQualityIndexTotalLandUseEffects.py +15 -10
  85. tests/models/frischknechtEtAl2000/test_ionisingRadiationKbqU235Eq.py +67 -44
  86. tests/models/impact_assessment/test_emissions.py +1 -0
  87. tests/models/ipcc2019/animal/test_fatContent.py +22 -0
  88. tests/models/ipcc2019/animal/test_liveweightGain.py +4 -2
  89. tests/models/ipcc2019/animal/test_liveweightPerHead.py +4 -2
  90. tests/models/ipcc2019/animal/test_pregnancyRateTotal.py +22 -0
  91. tests/models/ipcc2019/animal/test_trueProteinContent.py +22 -0
  92. tests/models/ipcc2019/animal/test_weightAtMaturity.py +2 -1
  93. tests/models/ipcc2019/test_aboveGroundBiomass.py +27 -63
  94. tests/models/ipcc2019/test_belowGroundBiomass.py +146 -0
  95. tests/models/ipcc2019/test_biomass_utils.py +115 -0
  96. tests/models/ipcc2019/{test_co2ToAirAboveGroundBiomassStockChangeLandUseChange.py → test_co2ToAirAboveGroundBiomassStockChange.py} +5 -5
  97. tests/models/ipcc2019/{test_co2ToAirBelowGroundBiomassStockChangeLandUseChange.py → test_co2ToAirBelowGroundBiomassStockChange.py} +5 -5
  98. tests/models/ipcc2019/{test_co2ToAirSoilOrganicCarbonStockChangeManagementChange.py → test_co2ToAirSoilOrganicCarbonStockChange.py} +5 -5
  99. tests/models/ipcc2021/test_gwp100.py +2 -2
  100. tests/models/poschEtAl2008/test_terrestrialAcidificationPotentialAccumulatedExceedance.py +30 -17
  101. tests/models/poschEtAl2008/test_terrestrialEutrophicationPotentialAccumulatedExceedance.py +28 -14
  102. hestia_earth/models/ipcc2019/aboveGroundBiomass_utils.py +0 -180
  103. tests/models/ipcc2019/test_aboveGroundBiomass_utils.py +0 -92
  104. {hestia_earth_models-0.64.8.dist-info → hestia_earth_models-0.64.10.dist-info}/LICENSE +0 -0
  105. {hestia_earth_models-0.64.8.dist-info → hestia_earth_models-0.64.10.dist-info}/top_level.txt +0 -0
@@ -1,11 +1,26 @@
1
+ from hestia_earth.schema import TermTermType
1
2
  from hestia_earth.utils.lookup import download_lookup, get_table_value, column_name, extract_grouped_data
2
- from hestia_earth.utils.tools import safe_parse_float
3
+ from hestia_earth.utils.model import filter_list_term_type
4
+ from hestia_earth.utils.tools import safe_parse_float, non_empty_list
3
5
 
4
- from hestia_earth.models.log import debugMissingLookup
6
+ from hestia_earth.models.log import logRequirements, logShouldRun, debugMissingLookup
7
+ from hestia_earth.models.utils.blank_node import merge_blank_nodes
8
+ from hestia_earth.models.utils.property import _new_property, node_has_no_property
5
9
  from hestia_earth.models.utils.productivity import PRODUCTIVITY, get_productivity
10
+ from hestia_earth.models.utils.term import get_lookup_value
6
11
  from .. import MODEL
7
12
 
8
13
 
14
+ def _get_practice(term_id: str, animal: dict, practice_id: str):
15
+ term = animal.get('term', {})
16
+ value = get_lookup_value(term, practice_id, model=MODEL, term=term_id)
17
+ practice_ids = non_empty_list((value or '').split(';'))
18
+ return next(
19
+ (p for p in animal.get('practices', []) if p.get('term', {}).get('@id') in practice_ids),
20
+ {}
21
+ )
22
+
23
+
9
24
  def productivity_lookup_value(term_id: str, lookup: str, country: dict, animal: dict):
10
25
  country_id = country.get('@id')
11
26
  productivity_key = get_productivity(country)
@@ -16,5 +31,74 @@ def productivity_lookup_value(term_id: str, lookup: str, country: dict, animal:
16
31
  debugMissingLookup(lookup_name, 'termid', country_id, column, value, model=MODEL, term=term_id)
17
32
  return safe_parse_float(
18
33
  extract_grouped_data(value, productivity_key.value) or
19
- extract_grouped_data(value, PRODUCTIVITY.HIGH.value) # defaults to high if low is not found
34
+ extract_grouped_data(value, PRODUCTIVITY.HIGH.value), # defaults to high if low is not found
35
+ None
20
36
  )
37
+
38
+
39
+ def map_live_animals_by_productivity_lookup(term_id: str, cycle: dict, lookup_col: str, practice_id: str = None):
40
+ country = cycle.get('site', {}).get('country', {})
41
+ live_animals = filter_list_term_type(cycle.get('animals', []), TermTermType.LIVEANIMAL)
42
+ live_animals = list(filter(node_has_no_property(term_id), live_animals))
43
+ return [{
44
+ 'animal': animal,
45
+ 'value': productivity_lookup_value(term_id, lookup_col, country, animal)
46
+ } | ({
47
+ 'practice': _get_practice(term_id, animal, practice_id)
48
+ } if practice_id else {}) for animal in live_animals]
49
+
50
+
51
+ def should_run_by_productivity_lookup(term_id: str, cycle: dict, lookup_col: str, practice_id: str = None):
52
+ country = cycle.get('site', {}).get('country', {})
53
+ country_id = country.get('@id')
54
+ live_animals_with_value = map_live_animals_by_productivity_lookup(term_id, cycle, lookup_col, practice_id)
55
+
56
+ def _should_run_animal(value: dict):
57
+ animal = value.get('animal')
58
+ lookup_value = value.get('value')
59
+ practice = value.get('practice')
60
+ term_id = animal.get('term').get('@id')
61
+
62
+ logRequirements(cycle, model=MODEL, term=term_id, property=term_id,
63
+ country_id=country_id,
64
+ **({
65
+ lookup_col.replace('-', '_'): lookup_value
66
+ } | ({
67
+ 'practice': practice.get('term', {}).get('@id')
68
+ } if practice_id else {})))
69
+
70
+ should_run = all([
71
+ country_id,
72
+ not practice_id or bool(practice),
73
+ lookup_value is not None
74
+ ])
75
+ logShouldRun(cycle, MODEL, term_id, should_run, property=term_id)
76
+
77
+ return should_run
78
+
79
+ return list(filter(_should_run_animal, live_animals_with_value))
80
+
81
+
82
+ def _property(term_id: str, value: float):
83
+ prop = _new_property(term_id, MODEL)
84
+ prop['value'] = value
85
+ return prop
86
+
87
+
88
+ def run_animal_by_productivity(term_id: str, include_practice: bool = False):
89
+ def run(data: dict):
90
+ animal = data.get('animal')
91
+ value = data.get('value')
92
+ practice = data.get('practice')
93
+ return animal | ({
94
+ 'practices': [
95
+ (
96
+ p | ({
97
+ 'properties': merge_blank_nodes(p.get('properties', []), [_property(term_id, value)])
98
+ } if p.get('term', {}).get('@id') == practice.get('term', {}).get('@id') else {})
99
+ ) for p in animal.get('practices', [])
100
+ ]
101
+ } if include_practice else {
102
+ 'properties': merge_blank_nodes(animal.get('properties', []), [_property(term_id, value)])
103
+ })
104
+ return run
@@ -2,13 +2,12 @@
2
2
  Note: when the `liveweightPerHead` property is provided, this model will only work if the returned value is
3
3
  greater than or equal to `liveweightPerHead` value.
4
4
  """
5
- from hestia_earth.schema import TermTermType
6
- from hestia_earth.utils.model import filter_list_term_type, find_term_match
5
+ from hestia_earth.utils.model import find_term_match
7
6
 
8
7
  from hestia_earth.models.log import logRequirements, logShouldRun
9
8
  from hestia_earth.models.utils.blank_node import merge_blank_nodes
10
- from hestia_earth.models.utils.property import _new_property, node_has_no_property
11
- from .utils import productivity_lookup_value
9
+ from hestia_earth.models.utils.property import _new_property
10
+ from .utils import map_live_animals_by_productivity_lookup
12
11
  from .. import MODEL
13
12
 
14
13
  REQUIREMENTS = {
@@ -68,12 +67,7 @@ def _run_animal(data: dict):
68
67
  def _should_run(cycle: dict):
69
68
  country = cycle.get('site', {}).get('country', {})
70
69
  country_id = country.get('@id')
71
- live_animals = filter_list_term_type(cycle.get('animals', []), TermTermType.LIVEANIMAL)
72
- live_animals = list(filter(node_has_no_property(TERM_ID), live_animals))
73
- live_animals_with_value = [{
74
- 'animal': animal,
75
- 'value': productivity_lookup_value(TERM_ID, list(LOOKUPS.keys())[0], country, animal)
76
- } for animal in live_animals]
70
+ live_animals_with_value = map_live_animals_by_productivity_lookup(TERM_ID, cycle, list(LOOKUPS.keys())[0])
77
71
 
78
72
  def _should_run_animal(value: dict):
79
73
  animal = value.get('animal')
@@ -0,0 +1,529 @@
1
+ from enum import Enum
2
+ from functools import reduce
3
+ from numpy import average, copy, random, vstack
4
+ from numpy.typing import NDArray
5
+ from typing import Optional, Union
6
+
7
+ from hestia_earth.schema import (
8
+ MeasurementMethodClassification,
9
+ MeasurementStatsDefinition,
10
+ SiteSiteType,
11
+ TermTermType
12
+ )
13
+
14
+ from hestia_earth.utils.model import filter_list_term_type
15
+ from hestia_earth.utils.tools import non_empty_list
16
+
17
+ from hestia_earth.models.log import log_as_table, logRequirements, logShouldRun
18
+ from hestia_earth.models.utils import pairwise
19
+ from hestia_earth.models.utils.array_builders import gen_seed
20
+ from hestia_earth.models.utils.blank_node import group_nodes_by_year
21
+ from hestia_earth.models.utils.descriptive_stats import calc_descriptive_stats
22
+ from hestia_earth.models.utils.ecoClimateZone import EcoClimateZone, get_eco_climate_zone_value
23
+ from hestia_earth.models.utils.measurement import _new_measurement
24
+
25
+ from . import MODEL
26
+ from .biomass_utils import (
27
+ BiomassCategory, detect_land_cover_change, group_by_biomass_category, sample_biomass_equilibrium,
28
+ summarise_land_cover_nodes
29
+ )
30
+
31
+
32
+ REQUIREMENTS = {
33
+ "Site": {
34
+ "management": [
35
+ {
36
+ "@type": "Management",
37
+ "value": "",
38
+ "term.termType": "landCover",
39
+ "endDate": "",
40
+ "optional": {
41
+ "startDate": ""
42
+ }
43
+ }
44
+ ],
45
+ "measurements": [
46
+ {
47
+ "@type": "Measurement",
48
+ "value": ["1", "2", "3", "4", "7", "8", "9", "10", "11", "12"],
49
+ "term.@id": "ecoClimateZone"
50
+ }
51
+ ],
52
+ "none": {
53
+ "siteType": ["glass or high accessible cover"]
54
+ }
55
+ }
56
+ }
57
+ LOOKUPS = {
58
+ "landCover": "BIOMASS_CATEGORY",
59
+ "ecoClimateZone": [
60
+ "BG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_FOREST",
61
+ "BG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_NATURAL_FOREST",
62
+ "BG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_PLANTATION_FOREST",
63
+ "BG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_OTHER"
64
+ ]
65
+ }
66
+ RETURNS = {
67
+ "Measurement": [{
68
+ "value": "",
69
+ "sd": "",
70
+ "min": "",
71
+ "max": "",
72
+ "statsDefinition": "simulated",
73
+ "observations": "",
74
+ "dates": "",
75
+ "methodClassification": "tier 1 model"
76
+ }]
77
+ }
78
+ TERM_ID = 'belowGroundBiomass'
79
+
80
+ _ITERATIONS = 10000
81
+ _METHOD_CLASSIFICATION = MeasurementMethodClassification.TIER_1_MODEL.value
82
+ _STATS_DEFINITION = MeasurementStatsDefinition.SIMULATED.value
83
+
84
+ _LAND_COVER_TERM_TYPE = TermTermType.LANDCOVER
85
+
86
+ _EQUILIBRIUM_TRANSITION_PERIOD = 20
87
+ _EXCLUDED_ECO_CLIMATE_ZONES = {EcoClimateZone.POLAR_MOIST, EcoClimateZone.POLAR_DRY}
88
+ _EXCLUDED_SITE_TYPES = {
89
+ SiteSiteType.GLASS_OR_HIGH_ACCESSIBLE_COVER.value
90
+ }
91
+
92
+ _VALID_BIOMASS_CATEGORIES = {
93
+ BiomassCategory.FOREST,
94
+ BiomassCategory.NATURAL_FOREST,
95
+ BiomassCategory.PLANTATION_FOREST
96
+ }
97
+ """Biomass stock data is only available in the lookups for these categories."""
98
+
99
+
100
+ class _InventoryKey(Enum):
101
+ """
102
+ The inner keys of the annualised inventory created by the `_compile_inventory` function.
103
+
104
+ The value of each enum member is formatted to be used as a column header in the `log_as_table` function.
105
+ """
106
+ BIOMASS_CATEGORY_SUMMARY = "biomass-categories"
107
+ LAND_COVER_CHANGE_EVENT = "lcc-event"
108
+ YEARS_SINCE_LCC_EVENT = "years-since-lcc-event"
109
+ REGIME_START_YEAR = "regime-start-year"
110
+
111
+
112
+ _REQUIRED_INVENTORY_KEYS = [e for e in _InventoryKey]
113
+
114
+
115
+ def run(site: dict) -> list[dict]:
116
+ """
117
+ Run the model on a Site.
118
+
119
+ Parameters
120
+ ----------
121
+ site : dict
122
+ A valid HESTIA [Site](https://www.hestia.earth/schema/Site).
123
+
124
+ Returns
125
+ -------
126
+ list[dict]
127
+ A list of HESTIA [Measurement](https://www.hestia.earth/schema/Measurement) nodes with `term.termType` =
128
+ `aboveGroundBiomass`
129
+ """
130
+ should_run, inventory, kwargs = _should_run(site)
131
+ return _run(inventory, iterations=_ITERATIONS, **kwargs) if should_run else []
132
+
133
+
134
+ def _should_run(site: dict) -> tuple[bool, dict, dict]:
135
+ """
136
+ Extract and re-organise required data from the input [Site](https://www.hestia.earth/schema/Site) node and determine
137
+ whether the model should run.
138
+
139
+ Parameters
140
+ ----------
141
+ site : dict
142
+ A valid HESTIA [Site](https://www.hestia.earth/schema/Site).
143
+
144
+ Returns
145
+ -------
146
+ tuple[bool, dict, dict]
147
+ should_run, inventory, kwargs
148
+ """
149
+ site_type = site.get("siteType")
150
+ eco_climate_zone = get_eco_climate_zone_value(site, as_enum=True)
151
+
152
+ land_cover = filter_list_term_type(site.get("management", []), _LAND_COVER_TERM_TYPE)
153
+
154
+ has_valid_site_type = site_type not in _EXCLUDED_SITE_TYPES
155
+ has_valid_eco_climate_zone = all([
156
+ eco_climate_zone,
157
+ eco_climate_zone not in _EXCLUDED_ECO_CLIMATE_ZONES
158
+ ])
159
+ has_land_cover_nodes = len(land_cover) > 0
160
+
161
+ should_compile_inventory = all([
162
+ has_valid_site_type,
163
+ has_valid_eco_climate_zone,
164
+ has_land_cover_nodes
165
+ ])
166
+
167
+ inventory = _compile_inventory(land_cover) if should_compile_inventory else {}
168
+ kwargs = {
169
+ "eco_climate_zone": eco_climate_zone,
170
+ "seed": gen_seed(site)
171
+ }
172
+
173
+ logRequirements(
174
+ site, model=MODEL, term=TERM_ID,
175
+ site_type=site_type,
176
+ has_valid_site_type=has_valid_site_type,
177
+ has_valid_eco_climate_zone=has_valid_eco_climate_zone,
178
+ has_land_cover_nodes=has_land_cover_nodes,
179
+ **kwargs,
180
+ inventory=_format_inventory(inventory)
181
+ )
182
+
183
+ should_run = all([
184
+ len(inventory) > 0,
185
+ all(data for data in inventory.values() if all(key in data.keys() for key in _REQUIRED_INVENTORY_KEYS))
186
+ ])
187
+
188
+ logShouldRun(site, MODEL, TERM_ID, should_run)
189
+
190
+ return should_run, inventory, kwargs
191
+
192
+
193
+ def _compile_inventory(land_cover_nodes: list[dict]) -> dict:
194
+ """
195
+ Build an annual inventory of model input data.
196
+
197
+ Returns a dict with shape:
198
+ ```
199
+ {
200
+ year (int): {
201
+ _InventoryKey.BIOMASS_CATEGORY_SUMMARY: {
202
+ category (BiomassCategory): value (float),
203
+ ...categories
204
+ },
205
+ _InventoryKey.LAND_COVER_CHANGE_EVENT: value (bool),
206
+ _InventoryKey.YEARS_SINCE_LCC_EVENT: value (int),
207
+ _InventoryKey.REGIME_START_YEAR: value (int)
208
+ },
209
+ ...years
210
+ }
211
+ ```
212
+
213
+ Parameters
214
+ ----------
215
+ land_cover_nodes : list[dict]
216
+ A list of HESTIA [Management](https://www.hestia.earth/schema/Measurement) nodes with `term.termType` =
217
+ `landCover`
218
+
219
+ Returns
220
+ -------
221
+ dict
222
+ The inventory of data.
223
+ """
224
+ land_cover_grouped = group_nodes_by_year(land_cover_nodes)
225
+
226
+ def build_inventory_year(inventory: dict, year_pair: tuple[int, int]) -> dict:
227
+ """
228
+ Build a year of the inventory using the data from `land_cover_categories_grouped`.
229
+
230
+ Parameters
231
+ ----------
232
+ inventory: dict
233
+ The land cover change portion of the inventory. Must have the same shape as the returned dict.
234
+ year_pair : tuple[int, int]
235
+ A tuple with the shape `(prev_year, current_year)`.
236
+
237
+ Returns
238
+ -------
239
+ dict
240
+ The land cover change portion of the inventory.
241
+ """
242
+
243
+ prev_year, current_year = year_pair
244
+ land_cover_nodes = land_cover_grouped.get(current_year, {})
245
+
246
+ biomass_category_summary = summarise_land_cover_nodes(land_cover_nodes, group_by_biomass_category)
247
+
248
+ prev_biomass_category_summary = inventory.get(prev_year, {}).get(_InventoryKey.BIOMASS_CATEGORY_SUMMARY, {})
249
+ is_lcc_event = detect_land_cover_change(biomass_category_summary, prev_biomass_category_summary)
250
+
251
+ time_delta = current_year - prev_year
252
+ prev_years_since_lcc_event = inventory.get(prev_year, {}).get(_InventoryKey.YEARS_SINCE_LCC_EVENT, 0)
253
+ years_since_lcc_event = time_delta if is_lcc_event else prev_years_since_lcc_event + time_delta
254
+ regime_start_year = current_year - years_since_lcc_event
255
+
256
+ update_dict = {
257
+ current_year: {
258
+ _InventoryKey.BIOMASS_CATEGORY_SUMMARY: biomass_category_summary,
259
+ _InventoryKey.LAND_COVER_CHANGE_EVENT: is_lcc_event,
260
+ _InventoryKey.YEARS_SINCE_LCC_EVENT: years_since_lcc_event,
261
+ _InventoryKey.REGIME_START_YEAR: regime_start_year
262
+ }
263
+ }
264
+ return inventory | update_dict
265
+
266
+ start_year = list(land_cover_grouped)[0]
267
+ initial_land_cover_nodes = land_cover_grouped.get(start_year, {})
268
+
269
+ initial = {
270
+ start_year: {
271
+ _InventoryKey.BIOMASS_CATEGORY_SUMMARY: summarise_land_cover_nodes(
272
+ initial_land_cover_nodes, group_by_biomass_category
273
+ ),
274
+ _InventoryKey.LAND_COVER_CHANGE_EVENT: False,
275
+ _InventoryKey.YEARS_SINCE_LCC_EVENT: _EQUILIBRIUM_TRANSITION_PERIOD,
276
+ _InventoryKey.REGIME_START_YEAR: start_year - _EQUILIBRIUM_TRANSITION_PERIOD
277
+ }
278
+ }
279
+
280
+ return reduce(
281
+ build_inventory_year,
282
+ pairwise(land_cover_grouped.keys()), # Inventory years need data from previous year to be compiled.
283
+ initial
284
+ )
285
+
286
+
287
+ def _format_inventory(inventory: dict) -> str:
288
+ """
289
+ Format the SOC inventory for logging as a table. Rows represent inventory years, columns represent soc stock change
290
+ data for each measurement method classification present in inventory. If the inventory is invalid, return `"None"`
291
+ as a string.
292
+ """
293
+ inventory_years = sorted(set(non_empty_list(years for years in inventory.keys())))
294
+ land_covers = _get_unique_categories(inventory, _InventoryKey.BIOMASS_CATEGORY_SUMMARY)
295
+ inventory_keys = _get_loggable_inventory_keys(inventory)
296
+
297
+ should_run = inventory and len(inventory_years) > 0
298
+
299
+ return log_as_table(
300
+ {
301
+ "year": year,
302
+ **{
303
+ _format_column_header(category): _format_number(
304
+ inventory.get(year, {}).get(_InventoryKey.BIOMASS_CATEGORY_SUMMARY, {}).get(category, 0)
305
+ ) for category in land_covers
306
+ },
307
+ **{
308
+ _format_column_header(key): _INVENTORY_KEY_TO_FORMAT_FUNC[key](
309
+ inventory.get(year, {}).get(key)
310
+ ) for key in inventory_keys
311
+ }
312
+ } for year in inventory_years
313
+ ) if should_run else "None"
314
+
315
+
316
+ def _get_unique_categories(inventory: dict, key: _InventoryKey) -> list:
317
+ """
318
+ Extract the unique biomass or land cover categories from the inventory.
319
+
320
+ Can be used to cache sampled parameters for each `BiomassCategory` or to log land covers.
321
+ """
322
+ categories = reduce(
323
+ lambda result, categories: result | set(categories),
324
+ (inner.get(key, {}).keys() for inner in inventory.values()),
325
+ set()
326
+ )
327
+ return sorted(
328
+ categories,
329
+ key=lambda category: category.value if isinstance(category, Enum) else str(category),
330
+ )
331
+
332
+
333
+ def _get_loggable_inventory_keys(inventory: dict) -> list:
334
+ """
335
+ Return a list of unique inventory keys in a fixed order.
336
+ """
337
+ unique_keys = reduce(
338
+ lambda result, keys: result | set(keys),
339
+ (
340
+ (key for key in group.keys() if key in _INVENTORY_KEY_TO_FORMAT_FUNC)
341
+ for group in inventory.values()
342
+ ),
343
+ set()
344
+ )
345
+ key_order = {key: i for i, key in enumerate(_INVENTORY_KEY_TO_FORMAT_FUNC.keys())}
346
+ return sorted(unique_keys, key=lambda key_: key_order[key_])
347
+
348
+
349
+ def _format_bool(value: Optional[bool]) -> str:
350
+ """Format a bool for logging in a table."""
351
+ return str(bool(value))
352
+
353
+
354
+ def _format_number(value: Optional[float]) -> str:
355
+ """Format a float for logging in a table."""
356
+ return f"{value:.1f}" if isinstance(value, (float, int)) else "None"
357
+
358
+
359
+ def _format_column_header(value: Union[_InventoryKey, BiomassCategory, str]):
360
+ """Format an enum or str for logging as a table column header."""
361
+ as_string = value.value if isinstance(value, Enum) else str(value)
362
+ return as_string.replace(" ", "-")
363
+
364
+
365
+ _INVENTORY_KEY_TO_FORMAT_FUNC = {
366
+ _InventoryKey.LAND_COVER_CHANGE_EVENT: _format_bool,
367
+ _InventoryKey.YEARS_SINCE_LCC_EVENT: _format_number
368
+ }
369
+ """
370
+ Map inventory keys to format functions. The columns in inventory logged as a table will also be sorted in the order of
371
+ the `dict` keys.
372
+ """
373
+
374
+
375
+ def _run(
376
+ inventory: dict,
377
+ *,
378
+ eco_climate_zone: EcoClimateZone,
379
+ iterations: int,
380
+ seed: Union[int, random.Generator, None] = None
381
+ ) -> list[dict]:
382
+ """
383
+ Calculate the annual above ground biomass stock based on an inventory of land cover data.
384
+
385
+ Inventory should be a dict with shape:
386
+ ```
387
+ {
388
+ year (int): {
389
+ _InventoryKey.BIOMASS_CATEGORY_SUMMARY: {
390
+ category (BiomassCategory): value (float),
391
+ ...categories
392
+ },
393
+ _InventoryKey.LAND_COVER_CHANGE_EVENT: value (bool),
394
+ _InventoryKey.YEARS_SINCE_LCC_EVENT: value (int),
395
+ _InventoryKey.REGIME_START_YEAR: value (int)
396
+ },
397
+ ...years
398
+ }
399
+ ```
400
+
401
+ Parameters
402
+ ----------
403
+ inventory : dict
404
+ The annual inventory of land cover data.
405
+ ecoClimateZone : EcoClimateZone
406
+ The eco-climate zone of the site.
407
+ iterations: int
408
+ The number of iterations to run the model as a Monte Carlo simulation.
409
+ seed : int | random.Generator | None
410
+ The seed for the random sampling of model parameters.
411
+
412
+ Returns
413
+ -------
414
+ list[dict]
415
+ A list of HESTIA [Measurement](https://www.hestia.earth/schema/Measurement) nodes with `term.termType` =
416
+ `aboveGroundBiomass`
417
+ """
418
+ rng = random.default_rng(seed)
419
+ unique_biomass_categories = _get_unique_categories(inventory, _InventoryKey.BIOMASS_CATEGORY_SUMMARY)
420
+
421
+ timestamps = list(inventory.keys())
422
+
423
+ factor_cache = {
424
+ category: sample_biomass_equilibrium(iterations, category, eco_climate_zone, _build_col_name, seed=rng)
425
+ for category in unique_biomass_categories
426
+ }
427
+
428
+ def get_average_equilibrium(year) -> NDArray:
429
+ biomass_categories = inventory.get(year, {}).get(_InventoryKey.BIOMASS_CATEGORY_SUMMARY, {})
430
+ values = [factor_cache.get(category) for category in biomass_categories.keys()]
431
+ weights = [weight for weight in biomass_categories.values()]
432
+ return average(values, axis=0, weights=weights)
433
+
434
+ equilibrium_annual = vstack([get_average_equilibrium(year) for year in inventory.keys()])
435
+
436
+ def calc_biomass_stock(result: NDArray, index_year: tuple[int, int]) -> NDArray:
437
+ index, year = index_year
438
+
439
+ years_since_llc_event = inventory.get(year, {}).get(_InventoryKey.YEARS_SINCE_LCC_EVENT, 0)
440
+ regime_start_year = inventory.get(year, {}).get(_InventoryKey.REGIME_START_YEAR, 0)
441
+ regime_start_index = (
442
+ timestamps.index(regime_start_year) if regime_start_year in timestamps else 0
443
+ )
444
+
445
+ regime_start_biomass = result[regime_start_index]
446
+ current_biomass_equilibrium = equilibrium_annual[index]
447
+
448
+ time_ratio = min(years_since_llc_event / _EQUILIBRIUM_TRANSITION_PERIOD, 1)
449
+ biomass_delta = (current_biomass_equilibrium - regime_start_biomass) * time_ratio
450
+
451
+ result[index] = regime_start_biomass + biomass_delta
452
+ return result
453
+
454
+ biomass_annual = reduce(
455
+ calc_biomass_stock,
456
+ list(enumerate(timestamps))[1:],
457
+ copy(equilibrium_annual)
458
+ )
459
+
460
+ descriptive_stats = calc_descriptive_stats(
461
+ biomass_annual,
462
+ _STATS_DEFINITION,
463
+ axis=1, # Calculate stats rowwise.
464
+ decimals=6 # Round values to the nearest milligram.
465
+ )
466
+ return [_measurement(timestamps, **descriptive_stats)]
467
+
468
+
469
+ def _build_col_name(biomass_category: BiomassCategory) -> str:
470
+ """
471
+ Get the column name for the `ecoClimateZone-lookup.csv` for a specific biomass category equilibrium.
472
+ """
473
+ COL_NAME_ROOT = "BG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_"
474
+ return (
475
+ f"{COL_NAME_ROOT}{biomass_category.name}" if biomass_category in _VALID_BIOMASS_CATEGORIES
476
+ else f"{COL_NAME_ROOT}OTHER"
477
+ )
478
+
479
+
480
+ def _measurement(
481
+ timestamps: list[int],
482
+ value: list[float],
483
+ *,
484
+ sd: list[float] = None,
485
+ min: list[float] = None,
486
+ max: list[float] = None,
487
+ statsDefinition: str = None,
488
+ observations: list[int] = None
489
+ ) -> dict:
490
+ """
491
+ Build a Hestia `Measurement` node to contain a value and descriptive statistics calculated by the models.
492
+
493
+ Parameters
494
+ ----------
495
+ timestamps : list[int]
496
+ A list of calendar years associated to the calculated SOC stocks.
497
+ value : list[float]
498
+ A list of values representing the mean biomass stock for each year of the inventory
499
+ sd : list[float]
500
+ A list of standard deviations representing the standard deviation of the biomass stock for each year of the
501
+ inventory.
502
+ min : list[float]
503
+ A list of minimum values representing the minimum modelled biomass stock for each year of the inventory.
504
+ max : list[float]
505
+ A list of maximum values representing the maximum modelled biomass stock for each year of the inventory.
506
+ statsDefinition : str
507
+ The [statsDefinition](https://www-staging.hestia.earth/schema/Measurement#statsDefinition) of the measurement.
508
+ observations : list[int]
509
+ The number of model iterations used to calculate the descriptive statistics.
510
+
511
+ Returns
512
+ -------
513
+ dict
514
+ A valid HESTIA `Measurement` node, see: https://www.hestia.earth/schema/Measurement.
515
+ """
516
+ update_dict = {
517
+ "value": value,
518
+ "sd": sd,
519
+ "min": min,
520
+ "max": max,
521
+ "statsDefinition": statsDefinition,
522
+ "observations": observations,
523
+ "dates": [f"{year}-12-31" for year in timestamps],
524
+ "methodClassification": _METHOD_CLASSIFICATION
525
+ }
526
+ measurement = _new_measurement(TERM_ID) | {
527
+ key: value for key, value in update_dict.items() if value
528
+ }
529
+ return measurement