hestia-earth-models 0.64.1__py3-none-any.whl → 0.64.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.
- hestia_earth/models/agribalyse2016/machineryInfrastructureDepreciatedAmountPerCycle.py +2 -2
- hestia_earth/models/cycle/animal/input/hestiaAggregatedData.py +5 -2
- hestia_earth/models/cycle/animal/input/properties.py +2 -1
- hestia_earth/models/cycle/animal/milkYield.py +2 -1
- hestia_earth/models/cycle/concentrateFeed.py +19 -10
- hestia_earth/models/cycle/cycleDuration.py +4 -5
- hestia_earth/models/cycle/siteDuration.py +15 -5
- hestia_earth/models/cycle/startDateDefinition.py +3 -4
- hestia_earth/models/cycle/stockingDensityAnimalHousingAverage.py +52 -0
- hestia_earth/models/ipcc2019/aboveGroundBiomass.py +762 -0
- hestia_earth/models/ipcc2019/aboveGroundBiomass_utils.py +180 -0
- hestia_earth/models/ipcc2019/animal/liveweightGain.py +88 -0
- hestia_earth/models/ipcc2019/animal/liveweightPerHead.py +88 -0
- hestia_earth/models/ipcc2019/animal/pastureGrass.py +51 -42
- hestia_earth/models/ipcc2019/animal/utils.py +20 -0
- hestia_earth/models/ipcc2019/animal/weightAtMaturity.py +10 -15
- hestia_earth/models/ipcc2019/ch4ToAirAquacultureSystems.py +96 -0
- hestia_earth/models/ipcc2019/ch4ToAirExcreta.py +2 -2
- hestia_earth/models/ipcc2019/organicCarbonPerHa_tier_1_utils.py +37 -50
- hestia_earth/models/ipcc2019/organicCarbonPerHa_utils.py +0 -19
- hestia_earth/models/ipcc2019/pastureGrass.py +44 -31
- hestia_earth/models/ipcc2019/pastureGrass_utils.py +38 -22
- hestia_earth/models/mocking/search-results.json +489 -249
- hestia_earth/models/poschEtAl2008/terrestrialEutrophicationPotentialAccumulatedExceedance.py +40 -0
- hestia_earth/models/utils/blank_node.py +20 -1
- hestia_earth/models/utils/crop.py +4 -0
- hestia_earth/models/utils/ecoClimateZone.py +99 -0
- hestia_earth/models/utils/productivity.py +1 -1
- hestia_earth/models/utils/property.py +2 -2
- hestia_earth/models/version.py +1 -1
- {hestia_earth_models-0.64.1.dist-info → hestia_earth_models-0.64.3.dist-info}/METADATA +1 -1
- {hestia_earth_models-0.64.1.dist-info → hestia_earth_models-0.64.3.dist-info}/RECORD +47 -31
- tests/models/cycle/test_siteDuration.py +22 -0
- tests/models/cycle/test_stockingDensityAnimalHousingAverage.py +42 -0
- tests/models/ipcc2019/animal/test_liveweightGain.py +20 -0
- tests/models/ipcc2019/animal/test_liveweightPerHead.py +20 -0
- tests/models/ipcc2019/animal/test_pastureGrass.py +1 -1
- tests/models/ipcc2019/test_aboveGroundBiomass.py +182 -0
- tests/models/ipcc2019/test_aboveGroundBiomass_utils.py +92 -0
- tests/models/ipcc2019/test_ch4ToAirAquacultureSystems.py +60 -0
- tests/models/ipcc2019/test_organicCarbonPerHa_tier_1_utils.py +3 -2
- tests/models/ipcc2019/test_pastureGrass.py +2 -2
- tests/models/poschEtAl2008/test_terrestrialEutrophicationPotentialAccumulatedExceedance.py +44 -0
- tests/models/utils/test_ecoClimateZone.py +152 -0
- {hestia_earth_models-0.64.1.dist-info → hestia_earth_models-0.64.3.dist-info}/LICENSE +0 -0
- {hestia_earth_models-0.64.1.dist-info → hestia_earth_models-0.64.3.dist-info}/WHEEL +0 -0
- {hestia_earth_models-0.64.1.dist-info → hestia_earth_models-0.64.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from functools import reduce
|
|
3
|
+
from math import isclose
|
|
4
|
+
from numpy import average, copy, random, vstack
|
|
5
|
+
from numpy.typing import NDArray
|
|
6
|
+
from typing import Callable, Optional, Union
|
|
7
|
+
|
|
8
|
+
from hestia_earth.schema import (
|
|
9
|
+
MeasurementMethodClassification,
|
|
10
|
+
MeasurementStatsDefinition,
|
|
11
|
+
SiteSiteType,
|
|
12
|
+
TermTermType
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from hestia_earth.utils.blank_node import get_node_value
|
|
16
|
+
from hestia_earth.utils.model import filter_list_term_type
|
|
17
|
+
from hestia_earth.utils.tools import non_empty_list
|
|
18
|
+
|
|
19
|
+
from hestia_earth.models.log import log_as_table, logRequirements, logShouldRun
|
|
20
|
+
from hestia_earth.models.utils import pairwise
|
|
21
|
+
from hestia_earth.models.utils.array_builders import gen_seed
|
|
22
|
+
from hestia_earth.models.utils.blank_node import group_nodes_by_year
|
|
23
|
+
from hestia_earth.models.utils.descriptive_stats import calc_descriptive_stats
|
|
24
|
+
from hestia_earth.models.utils.ecoClimateZone import EcoClimateZone, get_eco_climate_zone_value
|
|
25
|
+
from hestia_earth.models.utils.measurement import _new_measurement
|
|
26
|
+
from hestia_earth.models.utils.term import get_lookup_value
|
|
27
|
+
|
|
28
|
+
from . import MODEL
|
|
29
|
+
from .aboveGroundBiomass_utils import assign_biomass_category, BiomassCategory, sample_biomass_equilibrium
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
REQUIREMENTS = {
|
|
33
|
+
"Site": {
|
|
34
|
+
"siteType": ["cropland", "permanent pasture", "forest", "other natural vegetation"],
|
|
35
|
+
"management": [
|
|
36
|
+
{
|
|
37
|
+
"@type": "Management",
|
|
38
|
+
"value": "",
|
|
39
|
+
"term.termType": "landCover",
|
|
40
|
+
"endDate": "",
|
|
41
|
+
"optional": {
|
|
42
|
+
"startDate": ""
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"measurements": [
|
|
47
|
+
{
|
|
48
|
+
"@type": "Measurement",
|
|
49
|
+
"value": ["1", "2", "3", "4", "7", "8", "9", "10", "11", "12"],
|
|
50
|
+
"term.@id": "ecoClimateZone"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
LOOKUPS = {
|
|
56
|
+
"landCover": "BIOMASS_CATEGORY",
|
|
57
|
+
"ecoClimateZone": [
|
|
58
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_ANNUAL_CROPS",
|
|
59
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_COCONUT",
|
|
60
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_FOREST",
|
|
61
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_GRASSLAND",
|
|
62
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_JATROPHA",
|
|
63
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_JOJOBA",
|
|
64
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_NATURAL_FOREST",
|
|
65
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_OIL_PALM",
|
|
66
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_OLIVE",
|
|
67
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_ORCHARD",
|
|
68
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_PLANTATION_FOREST",
|
|
69
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_RUBBER",
|
|
70
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_SHORT_ROTATION_COPPICE",
|
|
71
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_TEA",
|
|
72
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_VINE",
|
|
73
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_WOODY_PERENNIAL",
|
|
74
|
+
"AG_BIOMASS_EQUILIBRIUM_KG_C_HECTARE_OTHER"
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
RETURNS = {
|
|
78
|
+
"Measurement": [{
|
|
79
|
+
"value": "",
|
|
80
|
+
"sd": "",
|
|
81
|
+
"min": "",
|
|
82
|
+
"max": "",
|
|
83
|
+
"statsDefinition": "simulated",
|
|
84
|
+
"observations": "",
|
|
85
|
+
"dates": "",
|
|
86
|
+
"methodClassification": "tier 1 model"
|
|
87
|
+
}]
|
|
88
|
+
}
|
|
89
|
+
TERM_ID = 'aboveGroundBiomass'
|
|
90
|
+
|
|
91
|
+
_ITERATIONS = 10000
|
|
92
|
+
_METHOD_CLASSIFICATION = MeasurementMethodClassification.TIER_1_MODEL.value
|
|
93
|
+
_STATS_DEFINITION = MeasurementStatsDefinition.SIMULATED.value
|
|
94
|
+
|
|
95
|
+
_LAND_COVER_TERM_TYPE = TermTermType.LANDCOVER
|
|
96
|
+
_TARGET_LAND_COVER = 100
|
|
97
|
+
|
|
98
|
+
_EQUILIBRIUM_TRANSITION_PERIOD = 20
|
|
99
|
+
_EXCLUDED_ECO_CLIMATE_ZONES = {EcoClimateZone.POLAR_MOIST, EcoClimateZone.POLAR_DRY}
|
|
100
|
+
_VALID_SITE_TYPES = {
|
|
101
|
+
SiteSiteType.CROPLAND.value,
|
|
102
|
+
SiteSiteType.FOREST.value,
|
|
103
|
+
SiteSiteType.OTHER_NATURAL_VEGETATION.value,
|
|
104
|
+
SiteSiteType.PERMANENT_PASTURE.value
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_GROUP_LAND_COVER_BY_BIOMASS_CATEGORY = [
|
|
108
|
+
BiomassCategory.ANNUAL_CROPS,
|
|
109
|
+
BiomassCategory.GRASSLAND,
|
|
110
|
+
BiomassCategory.OTHER,
|
|
111
|
+
BiomassCategory.SHORT_ROTATION_COPPICE
|
|
112
|
+
]
|
|
113
|
+
"""
|
|
114
|
+
Terms associated with these biomass categories can be grouped together when summarising land cover coverage in
|
|
115
|
+
`_group_by_term_id`.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _InventoryKey(Enum):
|
|
120
|
+
"""
|
|
121
|
+
The inner keys of the annualised inventory created by the `_compile_inventory` function.
|
|
122
|
+
|
|
123
|
+
The value of each enum member is formatted to be used as a column header in the `log_as_table` function.
|
|
124
|
+
"""
|
|
125
|
+
BIOMASS_CATEGORY_SUMMARY = "biomass-categories"
|
|
126
|
+
LAND_COVER_SUMMARY = "land-cover-categories"
|
|
127
|
+
LAND_COVER_CHANGE_EVENT = "lcc-event"
|
|
128
|
+
YEARS_SINCE_LCC_EVENT = "years-since-lcc-event"
|
|
129
|
+
REGIME_START_YEAR = "regime-start-year"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_REQUIRED_INVENTORY_KEYS = [e for e in _InventoryKey]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def run(site: dict) -> list[dict]:
|
|
136
|
+
"""
|
|
137
|
+
Run the model on a Site.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
site : dict
|
|
142
|
+
A valid HESTIA [Site](https://www.hestia.earth/schema/Site).
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
list[dict]
|
|
147
|
+
A list of HESTIA [Measurement](https://www.hestia.earth/schema/Measurement) nodes with `term.termType` =
|
|
148
|
+
`aboveGroundBiomass`
|
|
149
|
+
"""
|
|
150
|
+
should_run, inventory, kwargs = _should_run(site)
|
|
151
|
+
return _run(inventory, iterations=_ITERATIONS, **kwargs) if should_run else []
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _should_run(site: dict) -> tuple[bool, dict, dict]:
|
|
155
|
+
"""
|
|
156
|
+
Extract and re-organise required data from the input [Site](https://www.hestia.earth/schema/Site) node and determine
|
|
157
|
+
whether the model should run.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
site : dict
|
|
162
|
+
A valid HESTIA [Site](https://www.hestia.earth/schema/Site).
|
|
163
|
+
|
|
164
|
+
Returns
|
|
165
|
+
-------
|
|
166
|
+
tuple[bool, dict, dict]
|
|
167
|
+
should_run, inventory, kwargs
|
|
168
|
+
"""
|
|
169
|
+
site_type = site.get("siteType")
|
|
170
|
+
eco_climate_zone = get_eco_climate_zone_value(site, as_enum=True)
|
|
171
|
+
|
|
172
|
+
land_cover = filter_list_term_type(site.get("management", []), _LAND_COVER_TERM_TYPE)
|
|
173
|
+
|
|
174
|
+
has_valid_site_type = site_type in _VALID_SITE_TYPES
|
|
175
|
+
has_valid_eco_climate_zone = all([
|
|
176
|
+
eco_climate_zone,
|
|
177
|
+
eco_climate_zone not in _EXCLUDED_ECO_CLIMATE_ZONES
|
|
178
|
+
])
|
|
179
|
+
has_land_cover_nodes = len(land_cover) > 0
|
|
180
|
+
|
|
181
|
+
should_compile_inventory = all([
|
|
182
|
+
has_valid_site_type,
|
|
183
|
+
has_valid_eco_climate_zone,
|
|
184
|
+
has_land_cover_nodes
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
inventory = _compile_inventory(land_cover) if should_compile_inventory else {}
|
|
188
|
+
kwargs = {
|
|
189
|
+
"eco_climate_zone": eco_climate_zone,
|
|
190
|
+
"seed": gen_seed(site)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
logRequirements(
|
|
194
|
+
site, model=MODEL, term=TERM_ID,
|
|
195
|
+
site_type=site_type,
|
|
196
|
+
has_valid_site_type=has_valid_site_type,
|
|
197
|
+
has_valid_eco_climate_zone=has_valid_eco_climate_zone,
|
|
198
|
+
has_land_cover_nodes=has_land_cover_nodes,
|
|
199
|
+
**kwargs,
|
|
200
|
+
inventory=_format_inventory(inventory)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
should_run = all([
|
|
204
|
+
len(inventory) > 0,
|
|
205
|
+
all(data for data in inventory.values() if all(key in data.keys() for key in _REQUIRED_INVENTORY_KEYS))
|
|
206
|
+
])
|
|
207
|
+
|
|
208
|
+
logShouldRun(site, MODEL, TERM_ID, should_run)
|
|
209
|
+
|
|
210
|
+
return should_run, inventory, kwargs
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _compile_inventory(land_cover_nodes: list[dict]) -> dict:
|
|
214
|
+
"""
|
|
215
|
+
Build an annual inventory of model input data.
|
|
216
|
+
|
|
217
|
+
Returns a dict with shape:
|
|
218
|
+
```
|
|
219
|
+
{
|
|
220
|
+
year (int): {
|
|
221
|
+
_InventoryKey.BIOMASS_CATEGORY_SUMMARY: {
|
|
222
|
+
category (BiomassCategory): value (float),
|
|
223
|
+
...categories
|
|
224
|
+
},
|
|
225
|
+
_InventoryKey.LAND_COVER_SUMMARY: {
|
|
226
|
+
category (str | BiomassCategory): value (float),
|
|
227
|
+
...categories
|
|
228
|
+
},
|
|
229
|
+
_InventoryKey.LAND_COVER_CHANGE_EVENT: value (bool),
|
|
230
|
+
_InventoryKey.YEARS_SINCE_LCC_EVENT: value (int),
|
|
231
|
+
_InventoryKey.REGIME_START_YEAR: value (int)
|
|
232
|
+
},
|
|
233
|
+
...years
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Parameters
|
|
238
|
+
----------
|
|
239
|
+
land_cover_nodes : list[dict]
|
|
240
|
+
A list of HESTIA [Management](https://www.hestia.earth/schema/Measurement) nodes with `term.termType` =
|
|
241
|
+
`landCover`
|
|
242
|
+
|
|
243
|
+
Returns
|
|
244
|
+
-------
|
|
245
|
+
dict
|
|
246
|
+
The inventory of data.
|
|
247
|
+
"""
|
|
248
|
+
land_cover_grouped = group_nodes_by_year(land_cover_nodes)
|
|
249
|
+
|
|
250
|
+
def build_inventory_year(inventory: dict, year_pair: tuple[int, int]) -> dict:
|
|
251
|
+
"""
|
|
252
|
+
Build a year of the inventory using the data from `land_cover_categories_grouped`.
|
|
253
|
+
|
|
254
|
+
Parameters
|
|
255
|
+
----------
|
|
256
|
+
inventory: dict
|
|
257
|
+
The land cover change portion of the inventory. Must have the same shape as the returned dict.
|
|
258
|
+
year_pair : tuple[int, int]
|
|
259
|
+
A tuple with the shape `(prev_year, current_year)`.
|
|
260
|
+
|
|
261
|
+
Returns
|
|
262
|
+
-------
|
|
263
|
+
dict
|
|
264
|
+
The land cover change portion of the inventory.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
prev_year, current_year = year_pair
|
|
268
|
+
land_cover_nodes = land_cover_grouped.get(current_year, {})
|
|
269
|
+
|
|
270
|
+
biomass_category_summary = _summarise_land_cover_nodes(land_cover_nodes, _group_by_biomass_category)
|
|
271
|
+
land_cover_summary = _summarise_land_cover_nodes(land_cover_nodes, _group_by_term_id)
|
|
272
|
+
|
|
273
|
+
prev_land_cover_summary = inventory.get(prev_year, {}).get(_InventoryKey.LAND_COVER_SUMMARY, {})
|
|
274
|
+
|
|
275
|
+
is_lcc_event = _is_lcc_event(land_cover_summary, prev_land_cover_summary)
|
|
276
|
+
|
|
277
|
+
time_delta = current_year - prev_year
|
|
278
|
+
prev_years_since_lcc_event = inventory.get(prev_year, {}).get(_InventoryKey.YEARS_SINCE_LCC_EVENT, 0)
|
|
279
|
+
years_since_lcc_event = time_delta if is_lcc_event else prev_years_since_lcc_event + time_delta
|
|
280
|
+
regime_start_year = current_year - years_since_lcc_event
|
|
281
|
+
|
|
282
|
+
update_dict = {
|
|
283
|
+
current_year: {
|
|
284
|
+
_InventoryKey.BIOMASS_CATEGORY_SUMMARY: biomass_category_summary,
|
|
285
|
+
_InventoryKey.LAND_COVER_SUMMARY: land_cover_summary,
|
|
286
|
+
_InventoryKey.LAND_COVER_CHANGE_EVENT: is_lcc_event,
|
|
287
|
+
_InventoryKey.YEARS_SINCE_LCC_EVENT: years_since_lcc_event,
|
|
288
|
+
_InventoryKey.REGIME_START_YEAR: regime_start_year
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return inventory | update_dict
|
|
292
|
+
|
|
293
|
+
start_year = list(land_cover_grouped)[0]
|
|
294
|
+
initial_land_cover_nodes = land_cover_grouped.get(start_year, {})
|
|
295
|
+
|
|
296
|
+
initial = {
|
|
297
|
+
start_year: {
|
|
298
|
+
_InventoryKey.BIOMASS_CATEGORY_SUMMARY: _summarise_land_cover_nodes(
|
|
299
|
+
initial_land_cover_nodes, _group_by_biomass_category
|
|
300
|
+
),
|
|
301
|
+
_InventoryKey.LAND_COVER_SUMMARY: _summarise_land_cover_nodes(
|
|
302
|
+
initial_land_cover_nodes, _group_by_term_id
|
|
303
|
+
),
|
|
304
|
+
_InventoryKey.LAND_COVER_CHANGE_EVENT: False,
|
|
305
|
+
_InventoryKey.YEARS_SINCE_LCC_EVENT: _EQUILIBRIUM_TRANSITION_PERIOD,
|
|
306
|
+
_InventoryKey.REGIME_START_YEAR: start_year - _EQUILIBRIUM_TRANSITION_PERIOD
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return reduce(
|
|
311
|
+
build_inventory_year,
|
|
312
|
+
pairwise(land_cover_grouped.keys()), # Inventory years need data from previous year to be compiled.
|
|
313
|
+
initial
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _group_by_biomass_category(result: dict[BiomassCategory, float], node: dict) -> dict[BiomassCategory, float]:
|
|
318
|
+
"""
|
|
319
|
+
Reducer function for `_group_land_cover_nodes_by` that groups and sums node value by their associated
|
|
320
|
+
`BiomassCategory`.
|
|
321
|
+
|
|
322
|
+
Parameters
|
|
323
|
+
----------
|
|
324
|
+
result : dict
|
|
325
|
+
A dict with the shape `{category (BiomassCategory): sum_value (float), ...categories}`.
|
|
326
|
+
node : dict
|
|
327
|
+
A HESTIA `Management` node with `term.termType` = `landCover`.
|
|
328
|
+
|
|
329
|
+
Returns
|
|
330
|
+
-------
|
|
331
|
+
result : dict
|
|
332
|
+
A dict with the shape `{category (BiomassCategory): sum_value (float), ...categories}`.
|
|
333
|
+
"""
|
|
334
|
+
biomass_category = _retrieve_biomass_category(node)
|
|
335
|
+
value = get_node_value(node)
|
|
336
|
+
|
|
337
|
+
update_dict = {biomass_category: result.get(biomass_category, 0) + value}
|
|
338
|
+
|
|
339
|
+
should_run = biomass_category and value
|
|
340
|
+
return result | update_dict if should_run else result
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _group_by_term_id(
|
|
344
|
+
result: dict[Union[str, BiomassCategory], float], node: dict
|
|
345
|
+
) -> dict[Union[str, BiomassCategory], float]:
|
|
346
|
+
"""
|
|
347
|
+
Reducer function for `_group_land_cover_nodes_by` that groups and sums node value by their `term.@id` if a the land
|
|
348
|
+
cover is a woody plant, else by their associated `BiomassCategory`
|
|
349
|
+
|
|
350
|
+
Land cover events can be triggered by changes in land cover within the same `BiomassCategory` (e.g., `peachTree` to
|
|
351
|
+
`appleTree`) due to the requirement to clear the previous woody biomass to establish the new land cover.
|
|
352
|
+
|
|
353
|
+
Some land covers (e.g., land covers associated with the `BiomassCategory` = `Annual crops`, `Grassland`, `Other` or
|
|
354
|
+
`Short rotation coppice`) are exempt from this rule due to the Tier 1 assumptions that biomass does not accumulate
|
|
355
|
+
within the category or the maturity cycle of the land cover is significantly shorter than the amortisation period of
|
|
356
|
+
20 years.
|
|
357
|
+
|
|
358
|
+
Parameters
|
|
359
|
+
----------
|
|
360
|
+
result : dict
|
|
361
|
+
A dict with the shape `{category (str | BiomassCategory): sum_value (float), ...categories}`.
|
|
362
|
+
node : dict
|
|
363
|
+
A HESTIA `Management` node with `term.termType` = `landCover`.
|
|
364
|
+
|
|
365
|
+
Returns
|
|
366
|
+
-------
|
|
367
|
+
result : dict
|
|
368
|
+
A dict with the shape `{category (str | BiomassCategory): sum_value (float), ...categories}`.
|
|
369
|
+
"""
|
|
370
|
+
term_id = node.get("term", {}).get("@id")
|
|
371
|
+
biomass_category = _retrieve_biomass_category(node)
|
|
372
|
+
value = get_node_value(node)
|
|
373
|
+
|
|
374
|
+
key = biomass_category if biomass_category in _GROUP_LAND_COVER_BY_BIOMASS_CATEGORY else term_id
|
|
375
|
+
|
|
376
|
+
update_dict = {key: result.get(key, 0) + value}
|
|
377
|
+
|
|
378
|
+
should_run = biomass_category and value
|
|
379
|
+
return result | update_dict if should_run else result
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _retrieve_biomass_category(node: dict) -> Optional[BiomassCategory]:
|
|
383
|
+
"""
|
|
384
|
+
Retrieve the `BiomassCategory` associated with a land cover using the `BIOMASS_CATEGORY` lookup.
|
|
385
|
+
|
|
386
|
+
If lookup value is missing, return `None`.
|
|
387
|
+
|
|
388
|
+
Parameters
|
|
389
|
+
----------
|
|
390
|
+
node : dict
|
|
391
|
+
A valid `Management` node with `term.termType` = `landCover`.
|
|
392
|
+
|
|
393
|
+
Returns
|
|
394
|
+
-------
|
|
395
|
+
BiomassCategory | None
|
|
396
|
+
The associated `BiomassCategory` or `None`
|
|
397
|
+
"""
|
|
398
|
+
LOOKUP = LOOKUPS["landCover"]
|
|
399
|
+
term = node.get("term", {})
|
|
400
|
+
lookup_value = get_lookup_value(term, LOOKUP)
|
|
401
|
+
|
|
402
|
+
return assign_biomass_category(lookup_value) if lookup_value else None
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _summarise_land_cover_nodes(
|
|
406
|
+
land_cover_nodes: list[dict],
|
|
407
|
+
group_by_func: Callable[[dict, dict], dict] = _group_by_biomass_category
|
|
408
|
+
) -> dict[Union[str, BiomassCategory], float]:
|
|
409
|
+
"""
|
|
410
|
+
Group land cover nodes using `group_by_func`.
|
|
411
|
+
|
|
412
|
+
Parameters
|
|
413
|
+
----------
|
|
414
|
+
land_cover_nodes : list[dict]
|
|
415
|
+
A list of HESTIA `Management` nodes with `term.termType` = `landCover`.
|
|
416
|
+
|
|
417
|
+
Returns
|
|
418
|
+
-------
|
|
419
|
+
result : dict
|
|
420
|
+
A dict with the shape `{category (str | BiomassCategory): sum_value (float), ...categories}`.
|
|
421
|
+
"""
|
|
422
|
+
category_cover = reduce(group_by_func, land_cover_nodes, dict())
|
|
423
|
+
return _rescale_category_cover(category_cover)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _rescale_category_cover(
|
|
427
|
+
category_cover: dict[Union[BiomassCategory, str], float]
|
|
428
|
+
) -> dict[Union[BiomassCategory, str], float]:
|
|
429
|
+
"""
|
|
430
|
+
Enforce a land cover coverage of 100%.
|
|
431
|
+
|
|
432
|
+
If input coverage is less than 100%, fill the remainder with `BiomassCategory.OTHER`. If the input coverage is
|
|
433
|
+
greater than 100%, proportionally downscale all categories.
|
|
434
|
+
|
|
435
|
+
Parameters
|
|
436
|
+
----------
|
|
437
|
+
category_cover : dict[BiomassCategory | str, float]
|
|
438
|
+
The input category cover dict.
|
|
439
|
+
|
|
440
|
+
Returns
|
|
441
|
+
-------
|
|
442
|
+
result : dict[BiomassCategory | str, float]
|
|
443
|
+
The rescaled category cover dict.
|
|
444
|
+
"""
|
|
445
|
+
total_cover = sum(category_cover.values())
|
|
446
|
+
return (
|
|
447
|
+
_fill_category_cover(category_cover) if total_cover < _TARGET_LAND_COVER
|
|
448
|
+
else _squash_category_cover(category_cover) if total_cover > _TARGET_LAND_COVER
|
|
449
|
+
else category_cover
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _fill_category_cover(
|
|
454
|
+
category_cover: dict[Union[BiomassCategory, str], float]
|
|
455
|
+
) -> dict[Union[BiomassCategory, str], float]:
|
|
456
|
+
"""
|
|
457
|
+
Fill the land cover coverage with `BiomassCategory.OTHER` to enforce a total coverage of 100%.
|
|
458
|
+
|
|
459
|
+
Parameters
|
|
460
|
+
----------
|
|
461
|
+
category_cover : dict[BiomassCategory | str, float]
|
|
462
|
+
The input category cover dict.
|
|
463
|
+
|
|
464
|
+
Returns
|
|
465
|
+
-------
|
|
466
|
+
result : dict[BiomassCategory | str, float]
|
|
467
|
+
The rescaled category cover dict.
|
|
468
|
+
"""
|
|
469
|
+
total_cover = sum(category_cover.values())
|
|
470
|
+
update_dict = {
|
|
471
|
+
BiomassCategory.OTHER: category_cover.get(BiomassCategory.OTHER, 0) + (_TARGET_LAND_COVER - total_cover)
|
|
472
|
+
}
|
|
473
|
+
return category_cover | update_dict
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _squash_category_cover(
|
|
477
|
+
category_cover: dict[Union[BiomassCategory, str], float]
|
|
478
|
+
) -> dict[Union[BiomassCategory, str], float]:
|
|
479
|
+
"""
|
|
480
|
+
Proportionally shrink all land cover categories to enforce a total coverage of 100%.
|
|
481
|
+
|
|
482
|
+
Parameters
|
|
483
|
+
----------
|
|
484
|
+
category_cover : dict[BiomassCategory | str, float]
|
|
485
|
+
The input category cover dict.
|
|
486
|
+
|
|
487
|
+
Returns
|
|
488
|
+
-------
|
|
489
|
+
result : dict[BiomassCategory | str, float]
|
|
490
|
+
The rescaled category cover dict.
|
|
491
|
+
"""
|
|
492
|
+
total_cover = sum(category_cover.values())
|
|
493
|
+
return {
|
|
494
|
+
category: (cover / total_cover) * _TARGET_LAND_COVER
|
|
495
|
+
for category, cover in category_cover.items()
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _is_lcc_event(
|
|
500
|
+
a: dict[Union[BiomassCategory, str], float],
|
|
501
|
+
b: dict[Union[BiomassCategory, str], float]
|
|
502
|
+
) -> bool:
|
|
503
|
+
"""
|
|
504
|
+
Land cover values (% area) are compared with an absolute tolerance of 0.0001, which is equivalent to 1 m2 per
|
|
505
|
+
hectare.
|
|
506
|
+
|
|
507
|
+
Parameters
|
|
508
|
+
----------
|
|
509
|
+
a : dict[BiomassCategory | str, float]
|
|
510
|
+
The first land-cover summary dict.
|
|
511
|
+
b : dict[BiomassCategory | str, float]
|
|
512
|
+
The second land-cover summary dict.
|
|
513
|
+
|
|
514
|
+
Returns
|
|
515
|
+
-------
|
|
516
|
+
bool
|
|
517
|
+
Whether a land-cover change event has occured.
|
|
518
|
+
"""
|
|
519
|
+
keys_match = sorted(str(key) for key in b.keys()) == sorted(str(key) for key in a.keys())
|
|
520
|
+
values_close = all(
|
|
521
|
+
isclose(b.get(key), a.get(key, -999), abs_tol=0.0001) for key in b.keys()
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
return not all([keys_match, values_close])
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _format_inventory(inventory: dict) -> str:
|
|
528
|
+
"""
|
|
529
|
+
Format the SOC inventory for logging as a table. Rows represent inventory years, columns represent soc stock change
|
|
530
|
+
data for each measurement method classification present in inventory. If the inventory is invalid, return `"None"`
|
|
531
|
+
as a string.
|
|
532
|
+
"""
|
|
533
|
+
inventory_years = sorted(set(non_empty_list(years for years in inventory.keys())))
|
|
534
|
+
land_covers = _get_unique_categories(inventory, _InventoryKey.LAND_COVER_SUMMARY)
|
|
535
|
+
inventory_keys = _get_loggable_inventory_keys(inventory)
|
|
536
|
+
|
|
537
|
+
should_run = inventory and len(inventory_years) > 0
|
|
538
|
+
|
|
539
|
+
return log_as_table(
|
|
540
|
+
{
|
|
541
|
+
"year": year,
|
|
542
|
+
**{
|
|
543
|
+
_format_column_header(category): _format_number(
|
|
544
|
+
inventory.get(year, {}).get(_InventoryKey.LAND_COVER_SUMMARY, {}).get(category, 0)
|
|
545
|
+
) for category in land_covers
|
|
546
|
+
},
|
|
547
|
+
**{
|
|
548
|
+
_format_column_header(key): _INVENTORY_KEY_TO_FORMAT_FUNC[key](
|
|
549
|
+
inventory.get(year, {}).get(key)
|
|
550
|
+
) for key in inventory_keys
|
|
551
|
+
}
|
|
552
|
+
} for year in inventory_years
|
|
553
|
+
) if should_run else "None"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _get_unique_categories(inventory: dict, key: _InventoryKey) -> list:
|
|
557
|
+
"""
|
|
558
|
+
Extract the unique biomass or land cover categories from the inventory.
|
|
559
|
+
|
|
560
|
+
Can be used to cache sampled parameters for each `BiomassCategory` or to log land covers.
|
|
561
|
+
"""
|
|
562
|
+
categories = reduce(
|
|
563
|
+
lambda result, categories: result | set(categories),
|
|
564
|
+
(inner.get(key, {}).keys() for inner in inventory.values()),
|
|
565
|
+
set()
|
|
566
|
+
)
|
|
567
|
+
return sorted(
|
|
568
|
+
categories,
|
|
569
|
+
key=lambda category: category.value if isinstance(category, Enum) else str(category),
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _get_loggable_inventory_keys(inventory: dict) -> list:
|
|
574
|
+
"""
|
|
575
|
+
Return a list of unique inventory keys in a fixed order.
|
|
576
|
+
"""
|
|
577
|
+
unique_keys = reduce(
|
|
578
|
+
lambda result, keys: result | set(keys),
|
|
579
|
+
(
|
|
580
|
+
(key for key in group.keys() if key in _INVENTORY_KEY_TO_FORMAT_FUNC)
|
|
581
|
+
for group in inventory.values()
|
|
582
|
+
),
|
|
583
|
+
set()
|
|
584
|
+
)
|
|
585
|
+
key_order = {key: i for i, key in enumerate(_INVENTORY_KEY_TO_FORMAT_FUNC.keys())}
|
|
586
|
+
return sorted(unique_keys, key=lambda key_: key_order[key_])
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _format_bool(value: Optional[bool]) -> str:
|
|
590
|
+
"""Format a bool for logging in a table."""
|
|
591
|
+
return str(bool(value))
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _format_number(value: Optional[float]) -> str:
|
|
595
|
+
"""Format a float for logging in a table."""
|
|
596
|
+
return f"{value:.1f}" if isinstance(value, (float, int)) else "None"
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _format_column_header(value: Union[_InventoryKey, BiomassCategory, str]):
|
|
600
|
+
"""Format an enum or str for logging as a table column header."""
|
|
601
|
+
as_string = value.value if isinstance(value, Enum) else str(value)
|
|
602
|
+
return as_string.replace(" ", "-")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
_INVENTORY_KEY_TO_FORMAT_FUNC = {
|
|
606
|
+
_InventoryKey.LAND_COVER_CHANGE_EVENT: _format_bool,
|
|
607
|
+
_InventoryKey.YEARS_SINCE_LCC_EVENT: _format_number
|
|
608
|
+
}
|
|
609
|
+
"""
|
|
610
|
+
Map inventory keys to format functions. The columns in inventory logged as a table will also be sorted in the order of
|
|
611
|
+
the `dict` keys.
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _run(
|
|
616
|
+
inventory: dict,
|
|
617
|
+
*,
|
|
618
|
+
eco_climate_zone: EcoClimateZone,
|
|
619
|
+
iterations: int,
|
|
620
|
+
seed: Union[int, random.Generator, None] = None
|
|
621
|
+
) -> list[dict]:
|
|
622
|
+
"""
|
|
623
|
+
Calculate the annual above ground biomass stock based on an inventory of land cover data.
|
|
624
|
+
|
|
625
|
+
Inventory should be a dict with shape:
|
|
626
|
+
```
|
|
627
|
+
{
|
|
628
|
+
year (int): {
|
|
629
|
+
_InventoryKey.BIOMASS_CATEGORY_SUMMARY: {
|
|
630
|
+
category (BiomassCategory): value (float),
|
|
631
|
+
...categories
|
|
632
|
+
},
|
|
633
|
+
_InventoryKey.LAND_COVER_SUMMARY: {
|
|
634
|
+
category (str | BiomassCategory): value (float),
|
|
635
|
+
...categories
|
|
636
|
+
},
|
|
637
|
+
_InventoryKey.LAND_COVER_CHANGE_EVENT: value (bool),
|
|
638
|
+
_InventoryKey.YEARS_SINCE_LCC_EVENT: value (int),
|
|
639
|
+
_InventoryKey.REGIME_START_YEAR: value (int)
|
|
640
|
+
},
|
|
641
|
+
...years
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
Parameters
|
|
646
|
+
----------
|
|
647
|
+
inventory : dict
|
|
648
|
+
The annual inventory of land cover data.
|
|
649
|
+
ecoClimateZone : EcoClimateZone
|
|
650
|
+
The eco-climate zone of the site.
|
|
651
|
+
iterations: int
|
|
652
|
+
The number of iterations to run the model as a Monte Carlo simulation.
|
|
653
|
+
seed : int | random.Generator | None
|
|
654
|
+
The seed for the random sampling of model parameters.
|
|
655
|
+
|
|
656
|
+
Returns
|
|
657
|
+
-------
|
|
658
|
+
list[dict]
|
|
659
|
+
A list of HESTIA [Measurement](https://www.hestia.earth/schema/Measurement) nodes with `term.termType` =
|
|
660
|
+
`aboveGroundBiomass`
|
|
661
|
+
"""
|
|
662
|
+
rng = random.default_rng(seed)
|
|
663
|
+
unique_biomass_categories = _get_unique_categories(inventory, _InventoryKey.BIOMASS_CATEGORY_SUMMARY)
|
|
664
|
+
|
|
665
|
+
timestamps = list(inventory.keys())
|
|
666
|
+
|
|
667
|
+
factor_cache = {
|
|
668
|
+
category: sample_biomass_equilibrium(iterations, category, eco_climate_zone, rng)
|
|
669
|
+
for category in unique_biomass_categories
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
def get_average_equilibrium(year) -> NDArray:
|
|
673
|
+
biomass_categories = inventory.get(year, {}).get(_InventoryKey.BIOMASS_CATEGORY_SUMMARY, {})
|
|
674
|
+
values = [factor_cache.get(category) for category in biomass_categories.keys()]
|
|
675
|
+
weights = [weight for weight in biomass_categories.values()]
|
|
676
|
+
return average(values, axis=0, weights=weights)
|
|
677
|
+
|
|
678
|
+
equilibrium_annual = vstack([get_average_equilibrium(year) for year in inventory.keys()])
|
|
679
|
+
|
|
680
|
+
def calc_biomass_stock(result: NDArray, index_year: tuple[int, int]) -> NDArray:
|
|
681
|
+
index, year = index_year
|
|
682
|
+
|
|
683
|
+
years_since_llc_event = inventory.get(year, {}).get(_InventoryKey.YEARS_SINCE_LCC_EVENT, 0)
|
|
684
|
+
regime_start_year = inventory.get(year, {}).get(_InventoryKey.REGIME_START_YEAR, 0)
|
|
685
|
+
regime_start_index = (
|
|
686
|
+
timestamps.index(regime_start_year) if regime_start_year in timestamps else 0
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
regime_start_biomass = result[regime_start_index]
|
|
690
|
+
current_biomass_equilibrium = equilibrium_annual[index]
|
|
691
|
+
|
|
692
|
+
time_ratio = min(years_since_llc_event / _EQUILIBRIUM_TRANSITION_PERIOD, 1)
|
|
693
|
+
biomass_delta = (current_biomass_equilibrium - regime_start_biomass) * time_ratio
|
|
694
|
+
|
|
695
|
+
result[index] = regime_start_biomass + biomass_delta
|
|
696
|
+
return result
|
|
697
|
+
|
|
698
|
+
biomass_annual = reduce(
|
|
699
|
+
calc_biomass_stock,
|
|
700
|
+
list(enumerate(timestamps))[1:],
|
|
701
|
+
copy(equilibrium_annual)
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
descriptive_stats = calc_descriptive_stats(
|
|
705
|
+
biomass_annual,
|
|
706
|
+
_STATS_DEFINITION,
|
|
707
|
+
axis=1, # Calculate stats rowwise.
|
|
708
|
+
decimals=6 # Round values to the nearest milligram.
|
|
709
|
+
)
|
|
710
|
+
return [_measurement(timestamps, **descriptive_stats)]
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _measurement(
|
|
714
|
+
timestamps: list[int],
|
|
715
|
+
value: list[float],
|
|
716
|
+
*,
|
|
717
|
+
sd: list[float] = None,
|
|
718
|
+
min: list[float] = None,
|
|
719
|
+
max: list[float] = None,
|
|
720
|
+
statsDefinition: str = None,
|
|
721
|
+
observations: list[int] = None
|
|
722
|
+
) -> dict:
|
|
723
|
+
"""
|
|
724
|
+
Build a Hestia `Measurement` node to contain a value and descriptive statistics calculated by the models.
|
|
725
|
+
|
|
726
|
+
Parameters
|
|
727
|
+
----------
|
|
728
|
+
timestamps : list[int]
|
|
729
|
+
A list of calendar years associated to the calculated SOC stocks.
|
|
730
|
+
value : list[float]
|
|
731
|
+
A list of values representing the mean biomass stock for each year of the inventory
|
|
732
|
+
sd : list[float]
|
|
733
|
+
A list of standard deviations representing the standard deviation of the biomass stock for each year of the
|
|
734
|
+
inventory.
|
|
735
|
+
min : list[float]
|
|
736
|
+
A list of minimum values representing the minimum modelled biomass stock for each year of the inventory.
|
|
737
|
+
max : list[float]
|
|
738
|
+
A list of maximum values representing the maximum modelled biomass stock for each year of the inventory.
|
|
739
|
+
statsDefinition : str
|
|
740
|
+
The [statsDefinition](https://www-staging.hestia.earth/schema/Measurement#statsDefinition) of the measurement.
|
|
741
|
+
observations : list[int]
|
|
742
|
+
The number of model iterations used to calculate the descriptive statistics.
|
|
743
|
+
|
|
744
|
+
Returns
|
|
745
|
+
-------
|
|
746
|
+
dict
|
|
747
|
+
A valid HESTIA `Measurement` node, see: https://www.hestia.earth/schema/Measurement.
|
|
748
|
+
"""
|
|
749
|
+
update_dict = {
|
|
750
|
+
"value": value,
|
|
751
|
+
"sd": sd,
|
|
752
|
+
"min": min,
|
|
753
|
+
"max": max,
|
|
754
|
+
"statsDefinition": statsDefinition,
|
|
755
|
+
"observations": observations,
|
|
756
|
+
"dates": [f"{year}-12-31" for year in timestamps],
|
|
757
|
+
"methodClassification": _METHOD_CLASSIFICATION
|
|
758
|
+
}
|
|
759
|
+
measurement = _new_measurement(TERM_ID) | {
|
|
760
|
+
key: value for key, value in update_dict.items() if value
|
|
761
|
+
}
|
|
762
|
+
return measurement
|