hestia-earth-models 0.64.2__py3-none-any.whl → 0.64.4__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 (57) hide show
  1. hestia_earth/models/agribalyse2016/machineryInfrastructureDepreciatedAmountPerCycle.py +2 -2
  2. hestia_earth/models/cycle/animal/input/hestiaAggregatedData.py +5 -2
  3. hestia_earth/models/cycle/animal/input/properties.py +2 -1
  4. hestia_earth/models/cycle/animal/milkYield.py +2 -1
  5. hestia_earth/models/cycle/concentrateFeed.py +8 -8
  6. hestia_earth/models/cycle/cycleDuration.py +4 -5
  7. hestia_earth/models/cycle/siteDuration.py +15 -5
  8. hestia_earth/models/cycle/startDateDefinition.py +3 -4
  9. hestia_earth/models/cycle/stockingDensityAnimalHousingAverage.py +52 -0
  10. hestia_earth/models/fantkeEtAl2016/__init__.py +13 -0
  11. hestia_earth/models/fantkeEtAl2016/damageToHumanHealthParticulateMatterFormation.py +49 -0
  12. hestia_earth/models/frischknechtEtAl2000/__init__.py +13 -0
  13. hestia_earth/models/frischknechtEtAl2000/ionisingRadiationKbqU235Eq.py +90 -0
  14. hestia_earth/models/ipcc2019/aboveGroundBiomass.py +762 -0
  15. hestia_earth/models/ipcc2019/aboveGroundBiomass_utils.py +180 -0
  16. hestia_earth/models/ipcc2019/animal/liveweightGain.py +89 -0
  17. hestia_earth/models/ipcc2019/animal/liveweightPerHead.py +89 -0
  18. hestia_earth/models/ipcc2019/animal/pastureGrass.py +51 -42
  19. hestia_earth/models/ipcc2019/animal/utils.py +20 -0
  20. hestia_earth/models/ipcc2019/animal/weightAtMaturity.py +15 -19
  21. hestia_earth/models/ipcc2019/ch4ToAirExcreta.py +2 -2
  22. hestia_earth/models/ipcc2019/organicCarbonPerHa_tier_1_utils.py +37 -50
  23. hestia_earth/models/ipcc2019/organicCarbonPerHa_utils.py +0 -19
  24. hestia_earth/models/ipcc2019/pastureGrass.py +44 -31
  25. hestia_earth/models/ipcc2019/pastureGrass_utils.py +38 -22
  26. hestia_earth/models/mocking/search-results.json +228 -228
  27. hestia_earth/models/poschEtAl2008/terrestrialEutrophicationPotentialAccumulatedExceedance.py +40 -0
  28. hestia_earth/models/site/soilMeasurement.py +2 -2
  29. hestia_earth/models/utils/blank_node.py +20 -1
  30. hestia_earth/models/utils/crop.py +4 -0
  31. hestia_earth/models/utils/ecoClimateZone.py +99 -0
  32. hestia_earth/models/utils/emission.py +6 -2
  33. hestia_earth/models/utils/impact_assessment.py +10 -5
  34. hestia_earth/models/utils/lookup.py +5 -3
  35. hestia_earth/models/utils/productivity.py +1 -1
  36. hestia_earth/models/utils/property.py +2 -2
  37. hestia_earth/models/version.py +1 -1
  38. {hestia_earth_models-0.64.2.dist-info → hestia_earth_models-0.64.4.dist-info}/METADATA +1 -1
  39. {hestia_earth_models-0.64.2.dist-info → hestia_earth_models-0.64.4.dist-info}/RECORD +57 -35
  40. tests/models/cycle/test_siteDuration.py +22 -0
  41. tests/models/cycle/test_stockingDensityAnimalHousingAverage.py +42 -0
  42. tests/models/fantkeEtAl2016/__init__.py +0 -0
  43. tests/models/fantkeEtAl2016/test_damageToHumanHealthParticulateMatterFormation.py +20 -0
  44. tests/models/frischknechtEtAl2000/__init__.py +0 -0
  45. tests/models/frischknechtEtAl2000/test_ionisingRadiationKbqU235Eq.py +70 -0
  46. tests/models/ipcc2019/animal/test_liveweightGain.py +20 -0
  47. tests/models/ipcc2019/animal/test_liveweightPerHead.py +20 -0
  48. tests/models/ipcc2019/animal/test_pastureGrass.py +1 -1
  49. tests/models/ipcc2019/test_aboveGroundBiomass.py +182 -0
  50. tests/models/ipcc2019/test_aboveGroundBiomass_utils.py +92 -0
  51. tests/models/ipcc2019/test_organicCarbonPerHa_tier_1_utils.py +3 -2
  52. tests/models/ipcc2019/test_pastureGrass.py +2 -2
  53. tests/models/poschEtAl2008/test_terrestrialEutrophicationPotentialAccumulatedExceedance.py +44 -0
  54. tests/models/utils/test_ecoClimateZone.py +152 -0
  55. {hestia_earth_models-0.64.2.dist-info → hestia_earth_models-0.64.4.dist-info}/LICENSE +0 -0
  56. {hestia_earth_models-0.64.2.dist-info → hestia_earth_models-0.64.4.dist-info}/WHEEL +0 -0
  57. {hestia_earth_models-0.64.2.dist-info → hestia_earth_models-0.64.4.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