hestia-earth-models 0.67.1__py3-none-any.whl → 0.68.1__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.
- hestia_earth/models/aware/scarcityWeightedWaterUse.py +5 -6
- hestia_earth/models/blonkConsultants2016/ch4ToAirNaturalVegetationBurning.py +1 -1
- hestia_earth/models/blonkConsultants2016/co2ToAirAboveGroundBiomassStockChangeLandUseChange.py +1 -1
- hestia_earth/models/blonkConsultants2016/n2OToAirNaturalVegetationBurningDirect.py +1 -1
- hestia_earth/models/blonkConsultants2016/utils.py +9 -9
- hestia_earth/models/cache_sites.py +44 -23
- hestia_earth/models/chaudharyBrooks2018/damageToTerrestrialEcosystemsLandOccupation.py +2 -2
- hestia_earth/models/chaudharyBrooks2018/damageToTerrestrialEcosystemsLandTransformation.py +2 -2
- hestia_earth/models/chaudharyBrooks2018/utils.py +13 -8
- hestia_earth/models/cml2001Baseline/abioticResourceDepletionFossilFuels.py +2 -3
- hestia_earth/models/cml2001Baseline/abioticResourceDepletionMineralsAndMetals.py +1 -1
- hestia_earth/models/config/Cycle.json +15 -0
- hestia_earth/models/config/ImpactAssessment.json +14 -1
- hestia_earth/models/config/Site.json +8 -0
- hestia_earth/models/cycle/excretaKgMass.py +2 -2
- hestia_earth/models/cycle/materialAndSubstrate.py +3 -2
- hestia_earth/models/cycle/pastureGrass.py +3 -3
- hestia_earth/models/dammgen2009/noxToAirExcreta.py +1 -1
- hestia_earth/models/ecoinventV3AndEmberClimate/__init__.py +1 -1
- hestia_earth/models/ecoinventV3AndEmberClimate/utils.py +2 -6
- hestia_earth/models/emissionNotRelevant/__init__.py +4 -4
- hestia_earth/models/environmentalFootprintV3_1/environmentalFootprintSingleOverallScore.py +30 -21
- hestia_earth/models/environmentalFootprintV3_1/photochemicalOzoneCreationPotentialHumanHealthNmvocEq.py +36 -0
- hestia_earth/models/environmentalFootprintV3_1/scarcityWeightedWaterUse.py +2 -2
- hestia_earth/models/environmentalFootprintV3_1/soilQualityIndexLandOccupation.py +9 -8
- hestia_earth/models/environmentalFootprintV3_1/soilQualityIndexLandTransformation.py +25 -22
- hestia_earth/models/environmentalFootprintV3_1/soilQualityIndexTotalLandUseEffects.py +7 -6
- hestia_earth/models/faostat2018/coldCarcassWeightPerHead.py +2 -2
- hestia_earth/models/faostat2018/coldDressedCarcassWeightPerHead.py +2 -2
- hestia_earth/models/faostat2018/liveweightPerHead.py +7 -8
- hestia_earth/models/faostat2018/product/price.py +34 -28
- hestia_earth/models/faostat2018/readyToCookWeightPerHead.py +2 -2
- hestia_earth/models/faostat2018/utils.py +15 -27
- hestia_earth/models/frischknechtEtAl2000/ionisingRadiationKbqU235Eq.py +16 -9
- hestia_earth/models/geospatialDatabase/altitude.py +60 -0
- hestia_earth/models/geospatialDatabase/croppingIntensity.py +1 -1
- hestia_earth/models/geospatialDatabase/ecoClimateZone.py +2 -2
- hestia_earth/models/geospatialDatabase/longFallowRatio.py +1 -1
- hestia_earth/models/geospatialDatabase/utils.py +4 -1
- hestia_earth/models/globalCropWaterModel2008/rootingDepth.py +2 -3
- hestia_earth/models/haversineFormula/transport/distance.py +3 -3
- hestia_earth/models/hestia/landCover.py +72 -45
- hestia_earth/models/hestia/seed_emissions.py +11 -7
- hestia_earth/models/impact_assessment/__init__.py +3 -3
- hestia_earth/models/ipcc2019/animal/fatContent.py +1 -1
- hestia_earth/models/ipcc2019/animal/hoursWorkedPerDay.py +1 -1
- hestia_earth/models/ipcc2019/animal/liveweightGain.py +1 -1
- hestia_earth/models/ipcc2019/animal/liveweightPerHead.py +1 -1
- hestia_earth/models/ipcc2019/animal/milkYieldPerAnimal.py +1 -1
- hestia_earth/models/ipcc2019/animal/pastureGrass.py +1 -1
- hestia_earth/models/ipcc2019/animal/pregnancyRateTotal.py +1 -1
- hestia_earth/models/ipcc2019/animal/trueProteinContent.py +1 -1
- hestia_earth/models/ipcc2019/animal/utils.py +5 -7
- hestia_earth/models/ipcc2019/animal/weightAtMaturity.py +1 -1
- hestia_earth/models/ipcc2019/ch4ToAirEntericFermentation.py +2 -2
- hestia_earth/models/ipcc2019/ch4ToAirExcreta.py +6 -7
- hestia_earth/models/ipcc2019/ch4ToAirFloodedRice.py +5 -3
- hestia_earth/models/ipcc2019/co2ToAirCarbonStockChange_utils.py +1 -1
- hestia_earth/models/ipcc2019/croppingDuration.py +3 -6
- hestia_earth/models/ipcc2019/nonCo2EmissionsToAirNaturalVegetationBurning.py +947 -0
- hestia_earth/models/ipcc2019/pastureGrass.py +1 -1
- hestia_earth/models/koble2014/residueBurnt.py +5 -7
- hestia_earth/models/koble2014/residueRemoved.py +5 -7
- hestia_earth/models/lcImpactAllEffects100Years/damageToHumanHealthWaterStress.py +2 -2
- hestia_earth/models/lcImpactAllEffectsInfinite/damageToHumanHealthWaterStress.py +2 -2
- hestia_earth/models/lcImpactCertainEffects100Years/damageToHumanHealthWaterStress.py +2 -2
- hestia_earth/models/lcImpactCertainEffectsInfinite/damageToHumanHealthWaterStress.py +2 -2
- hestia_earth/models/log.py +1 -1
- hestia_earth/models/mocking/search-results.json +3397 -1097
- hestia_earth/models/site/management.py +1 -1
- hestia_earth/models/site/post_checks/__init__.py +3 -2
- hestia_earth/models/site/post_checks/country.py +9 -0
- hestia_earth/models/site/pre_checks/__init__.py +3 -2
- hestia_earth/models/site/pre_checks/country.py +9 -0
- hestia_earth/models/site/soilMeasurement.py +2 -0
- hestia_earth/models/utils/__init__.py +1 -16
- hestia_earth/models/utils/blank_node.py +25 -25
- hestia_earth/models/utils/completeness.py +3 -2
- hestia_earth/models/utils/cycle.py +5 -4
- hestia_earth/models/utils/emission.py +5 -5
- hestia_earth/models/utils/feedipedia.py +6 -6
- hestia_earth/models/utils/impact_assessment.py +1 -2
- hestia_earth/models/utils/indicator.py +9 -7
- hestia_earth/models/utils/inorganicFertiliser.py +4 -6
- hestia_earth/models/utils/input.py +6 -5
- hestia_earth/models/utils/lookup.py +32 -100
- hestia_earth/models/utils/management.py +4 -4
- hestia_earth/models/utils/measurement.py +7 -8
- hestia_earth/models/utils/method.py +20 -0
- hestia_earth/models/utils/practice.py +4 -5
- hestia_earth/models/utils/product.py +4 -5
- hestia_earth/models/utils/property.py +12 -22
- hestia_earth/models/utils/site.py +14 -8
- hestia_earth/models/utils/term.py +27 -1
- hestia_earth/models/version.py +1 -1
- hestia_earth/orchestrator/log.py +0 -11
- hestia_earth/orchestrator/models/__init__.py +17 -4
- hestia_earth/orchestrator/strategies/run/add_blank_node_if_missing.py +2 -20
- {hestia_earth_models-0.67.1.dist-info → hestia_earth_models-0.68.1.dist-info}/METADATA +2 -2
- {hestia_earth_models-0.67.1.dist-info → hestia_earth_models-0.68.1.dist-info}/RECORD +146 -138
- tests/models/cml2001Baseline/test_abioticResourceDepletionFossilFuels.py +3 -3
- tests/models/cml2001Baseline/test_resourceUseEnergyDepletionDuringCycle.py +1 -1
- tests/models/cycle/test_coldCarcassWeightPerHead.py +1 -1
- tests/models/cycle/test_coldDressedCarcassWeightPerHead.py +1 -1
- tests/models/cycle/test_concentrateFeed.py +1 -1
- tests/models/cycle/test_energyContentLowerHeatingValue.py +1 -1
- tests/models/cycle/test_excretaKgMass.py +1 -1
- tests/models/cycle/test_feedConversionRatio.py +3 -3
- tests/models/cycle/test_pastureGrass.py +1 -1
- tests/models/cycle/test_readyToCookWeightPerHead.py +1 -1
- tests/models/environmentalFootprintV3_1/test_photochemicalOzoneCreationPotentialHumanHealthNmvocEq.py +30 -0
- tests/models/environmentalFootprintV3_1/test_soilQualityIndexTotalLandUseEffects.py +30 -7
- tests/models/faostat2018/product/test_price.py +27 -14
- tests/models/faostat2018/test_faostat_utils.py +4 -24
- tests/models/faostat2018/test_liveweightPerHead.py +9 -9
- tests/models/globalCropWaterModel2008/test_rootingDepth.py +7 -3
- tests/models/haversineFormula/transport/test_distance.py +1 -1
- tests/models/hestia/test_landCover.py +53 -5
- tests/models/ipcc2019/animal/test_pastureGrass.py +5 -3
- tests/models/ipcc2019/test_aboveGroundCropResidueTotal.py +4 -4
- tests/models/ipcc2019/test_belowGroundCropResidue.py +4 -4
- tests/models/ipcc2019/test_ch4ToAirEntericFermentation.py +10 -10
- tests/models/ipcc2019/test_croppingDuration.py +1 -1
- tests/models/ipcc2019/test_nonCo2EmissionsToAirNaturalVegetationBurning.py +83 -0
- tests/models/ipcc2019/test_organicCarbonPerHa.py +12 -12
- tests/models/ipcc2019/test_pastureGrass.py +5 -3
- tests/models/pooreNemecek2018/test_excretaKgN.py +5 -5
- tests/models/pooreNemecek2018/test_excretaKgVs.py +2 -2
- tests/models/site/post_checks/test_country.py +6 -0
- tests/models/site/pre_checks/test_cache_geospatialDatabase.py +1 -1
- tests/models/site/pre_checks/test_country.py +12 -0
- tests/models/test_ecoinventV3.py +7 -3
- tests/models/utils/test_blank_node.py +4 -12
- tests/models/utils/test_dataCompleteness.py +5 -5
- tests/models/utils/test_emission.py +2 -2
- tests/models/utils/test_indicator.py +2 -2
- tests/models/utils/test_input.py +2 -2
- tests/models/utils/test_measurement.py +2 -4
- tests/models/utils/test_practice.py +4 -2
- tests/models/utils/test_product.py +2 -2
- tests/models/utils/test_property.py +4 -2
- tests/models/utils/test_site.py +7 -0
- tests/orchestrator/strategies/run/test_add_blank_node_if_missing.py +4 -9
- hestia_earth/models/environmentalFootprintV3_1/utils.py +0 -17
- tests/models/utils/test_lookup.py +0 -10
- {hestia_earth_models-0.67.1.dist-info → hestia_earth_models-0.68.1.dist-info}/LICENSE +0 -0
- {hestia_earth_models-0.67.1.dist-info → hestia_earth_models-0.68.1.dist-info}/WHEEL +0 -0
- {hestia_earth_models-0.67.1.dist-info → hestia_earth_models-0.68.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,947 @@
|
|
1
|
+
"""
|
2
|
+
Non-CO2 emissions, to air, natural vegetation burning
|
3
|
+
|
4
|
+
This model returns the amounts of non-CO2 emissions from natural vegetation burning:
|
5
|
+
1. [CH4, to air, natural vegetation burning](https://www.hestia.earth/term/ch4ToAirNaturalVegetationBurning);
|
6
|
+
2. [CO, to air, natural vegetation burning](https://www.hestia.earth/term/coToAirNaturalVegetationBurning);
|
7
|
+
3. [N2O, to air, natural vegetation burning](https://www.hestia.earth/term/n2OToAirNaturalVegetationBurningDirect);
|
8
|
+
4. [NOx, to air, natural vegetation burning](https://www.hestia.earth/term/noxToAirNaturalVegetationBurning).
|
9
|
+
|
10
|
+
For now, the V1 version of this model only calculates emissions from forests burning, as we have no reliable way of
|
11
|
+
recording savannah grassland and woodland fire regimes using the HESTIA glossary.
|
12
|
+
"""
|
13
|
+
from enum import Enum
|
14
|
+
from functools import lru_cache, reduce
|
15
|
+
from itertools import product
|
16
|
+
import numpy as np
|
17
|
+
import numpy.typing as npt
|
18
|
+
from typing import Any, Callable, Literal, Optional, TypedDict, Union
|
19
|
+
|
20
|
+
from hestia_earth.schema import EmissionMethodTier, EmissionStatsDefinition, SiteSiteType
|
21
|
+
from hestia_earth.utils.lookup import download_lookup, get_table_value, column_name
|
22
|
+
from hestia_earth.utils.tools import safe_parse_float
|
23
|
+
|
24
|
+
from hestia_earth.models.log import debugMissingLookup, log_as_table, logRequirements, logShouldRun
|
25
|
+
from hestia_earth.models.utils.array_builders import gen_seed, repeat_single, truncated_normal_1d
|
26
|
+
from hestia_earth.models.utils.blank_node import group_nodes_by_year
|
27
|
+
from hestia_earth.models.utils.descriptive_stats import calc_descriptive_stats
|
28
|
+
from hestia_earth.models.utils.ecoClimateZone import EcoClimateZone, get_eco_climate_zone_value
|
29
|
+
from hestia_earth.models.utils.emission import _new_emission
|
30
|
+
from hestia_earth.models.utils.lookup import get_region_lookup_value
|
31
|
+
from hestia_earth.models.utils.site import related_cycles
|
32
|
+
|
33
|
+
from . import MODEL
|
34
|
+
from .biomass_utils import BiomassCategory, get_valid_management_nodes, summarise_land_cover_nodes
|
35
|
+
|
36
|
+
REQUIREMENTS = {
|
37
|
+
"Cycle": {
|
38
|
+
"site": {
|
39
|
+
"management": [
|
40
|
+
{
|
41
|
+
"@type": "Management",
|
42
|
+
"value": "",
|
43
|
+
"term.termType": "landCover",
|
44
|
+
"endDate": "",
|
45
|
+
"optional": {
|
46
|
+
"startDate": ""
|
47
|
+
}
|
48
|
+
}
|
49
|
+
],
|
50
|
+
"measurements": [
|
51
|
+
{
|
52
|
+
"@type": "Measurement",
|
53
|
+
"value": "",
|
54
|
+
"term.@id": "ecoClimateZone",
|
55
|
+
"none": {
|
56
|
+
"value": ["5, 6"]
|
57
|
+
}
|
58
|
+
}
|
59
|
+
],
|
60
|
+
"none": {
|
61
|
+
"siteType": ["glass or high accessible cover"]
|
62
|
+
}
|
63
|
+
}
|
64
|
+
}
|
65
|
+
}
|
66
|
+
LOOKUPS = {
|
67
|
+
"emissionToAirNaturalVegetationBurning_emissionCategory_gEmittedPerKgDryMatterCombusted": [
|
68
|
+
"IPCC_2019_G_EMITTED_PER_KG_DRY_MATTER_COMBUSTED_{EMISSION_CATEGORY}_value",
|
69
|
+
"IPCC_2019_G_EMITTED_PER_KG_DRY_MATTER_COMBUSTED_{EMISSION_CATEGORY}_sd"
|
70
|
+
],
|
71
|
+
"ipcc2019FuelCategory_tonnesDryMatterCombustedPerHaBurned": "value",
|
72
|
+
"landCover": "BIOMASS_CATEGORY",
|
73
|
+
"region-percentageAreaBurnedDuringForestClearance": "percentage_area_burned_during_forest_clearance"
|
74
|
+
}
|
75
|
+
RETURNS = {
|
76
|
+
"Emission": [{
|
77
|
+
"value": "",
|
78
|
+
"sd": "",
|
79
|
+
"min": "",
|
80
|
+
"max": "",
|
81
|
+
"statsDefinition": "simulated",
|
82
|
+
"observations": "",
|
83
|
+
"dates": "",
|
84
|
+
"methodClassification": "tier 1 model"
|
85
|
+
}]
|
86
|
+
}
|
87
|
+
TERM_ID = 'ch4ToAirNaturalVegetationBurning,coToAirNaturalVegetationBurning,n2OToAirNaturalVegetationBurningDirect,noxToAirNaturalVegetationBurning' # noqa: E501
|
88
|
+
|
89
|
+
EMISSION_TERM_IDS = TERM_ID.split(",")
|
90
|
+
TIER = EmissionMethodTier.TIER_1.value
|
91
|
+
STATS_DEFINITION = EmissionStatsDefinition.SIMULATED.value
|
92
|
+
|
93
|
+
_ITERATIONS = 10000 # N interations for which the model will run as a Monte Carlo simulation
|
94
|
+
_AMORTISATION_PERIOD = 20 # Emissions should be amortised over 20 years
|
95
|
+
|
96
|
+
_EXCLUDED_ECO_CLIMATE_ZONES = {EcoClimateZone.POLAR_MOIST, EcoClimateZone.POLAR_DRY}
|
97
|
+
_EXCLUDED_SITE_TYPES = {SiteSiteType.GLASS_OR_HIGH_ACCESSIBLE_COVER.value}
|
98
|
+
_NATURAL_VEGETATION_CATEGORIES = {
|
99
|
+
BiomassCategory.FOREST,
|
100
|
+
BiomassCategory.NATURAL_FOREST,
|
101
|
+
BiomassCategory.PLANTATION_FOREST
|
102
|
+
}
|
103
|
+
_DEFAULT_FACTOR = {"value": 0}
|
104
|
+
_DEFAULT_PERCENT_BURNED = 0
|
105
|
+
|
106
|
+
|
107
|
+
class _FuelCategory(Enum):
|
108
|
+
"""
|
109
|
+
Natural vegetation fuel categories from IPCC (2019).
|
110
|
+
"""
|
111
|
+
BOREAL_FOREST = "boreal-forest"
|
112
|
+
EUCALYPT_FOREST = "eucalypt-forest"
|
113
|
+
NATURAL_TROPICAL_FOREST = "natural-tropical-forest" # mean of primary and secondary tropical forest
|
114
|
+
PRIMARY_TROPICAL_FOREST = "primary-tropical-forest"
|
115
|
+
SAVANNA_GRASSLAND_EARLY_DRY_SEASON_BURNS = "savanna-grassland-early-dry-season-burns"
|
116
|
+
SAVANNA_GRASSLAND_MID_TO_LATE_DRY_SEASON_BURNS = "savanna-grassland-mid-to-late-dry-season-burns"
|
117
|
+
SAVANNA_WOODLAND_EARLY_DRY_SEASON_BURNS = "savanna-woodland-early-dry-season-burns"
|
118
|
+
SAVANNA_WOODLAND_MID_TO_LATE_DRY_SEASON_BURNS = "savanna-woodland-mid-to-late-dry-season-burns"
|
119
|
+
SECONDARY_TROPICAL_FOREST = "secondary-tropical-forest"
|
120
|
+
SHRUBLAND = "shrubland"
|
121
|
+
TEMPERATE_FOREST = "temperate-forest"
|
122
|
+
TERTIARY_TROPICAL_FOREST = "tertiary-tropical-forest"
|
123
|
+
UNKNOWN_TROPICAL_FOREST = "unknown-tropical-forest" # mean of primary, secondary and tertiary tropical forest
|
124
|
+
|
125
|
+
|
126
|
+
class _EmissionCategory(Enum):
|
127
|
+
"""
|
128
|
+
Natural vegetation burning emission categories from IPCC (2019).
|
129
|
+
"""
|
130
|
+
AGRICULTURAL_RESIDUES = "agricultural-residues"
|
131
|
+
BIOFUEL_BURNING = "biofuel-burning"
|
132
|
+
OTHER_FOREST = "other-forest"
|
133
|
+
SAVANNA_AND_GRASSLAND = "savanna-and-grassland"
|
134
|
+
TROPICAL_FOREST = "tropical-forest"
|
135
|
+
|
136
|
+
|
137
|
+
_EmissionTermId = Literal[
|
138
|
+
"ch4ToAirNaturalVegetationBurning",
|
139
|
+
"coToAirNaturalVegetationBurning",
|
140
|
+
"n2OToAirNaturalVegetationBurningDirect",
|
141
|
+
"noxToAirNaturalVegetationBurning"
|
142
|
+
]
|
143
|
+
|
144
|
+
|
145
|
+
class _InventoryYear(TypedDict, total=False):
|
146
|
+
biomass_category_summary: dict[BiomassCategory, float]
|
147
|
+
natural_vegetation_delta: dict[BiomassCategory, float]
|
148
|
+
fuel_burnt_per_category: dict[_FuelCategory, npt.NDArray]
|
149
|
+
annual_emissions: dict[_EmissionTermId, npt.NDArray]
|
150
|
+
amortised_emissions: dict[_EmissionTermId, npt.NDArray]
|
151
|
+
share_of_emissions: dict[str, float] # {cycle_id (str): value, ...}
|
152
|
+
allocated_emissions: dict[_EmissionTermId, dict[str, npt.NDArray]]
|
153
|
+
|
154
|
+
|
155
|
+
_InventoryKey = Literal[
|
156
|
+
"biomass_category_summary",
|
157
|
+
"natural_vegetation_delta",
|
158
|
+
"fuel_burnt_per_category",
|
159
|
+
"annual_emissions",
|
160
|
+
"amortised_emissions",
|
161
|
+
"share_of_emissions",
|
162
|
+
"allocated_emissions"
|
163
|
+
]
|
164
|
+
|
165
|
+
_Inventory = dict[int, _InventoryYear]
|
166
|
+
"""
|
167
|
+
{year (int): data (_InventoryYear)}
|
168
|
+
"""
|
169
|
+
|
170
|
+
_EmissionInventory = dict[_EmissionTermId, npt.NDArray]
|
171
|
+
|
172
|
+
|
173
|
+
_BIOMASS_CATEGORY_TO_FUEL_CATEGORY = {
|
174
|
+
BiomassCategory.FOREST: {
|
175
|
+
EcoClimateZone.WARM_TEMPERATE_MOIST: _FuelCategory.TEMPERATE_FOREST,
|
176
|
+
EcoClimateZone.WARM_TEMPERATE_DRY: _FuelCategory.TEMPERATE_FOREST,
|
177
|
+
EcoClimateZone.COOL_TEMPERATE_MOIST: _FuelCategory.TEMPERATE_FOREST,
|
178
|
+
EcoClimateZone.COOL_TEMPERATE_DRY: _FuelCategory.TEMPERATE_FOREST,
|
179
|
+
EcoClimateZone.BOREAL_MOIST: _FuelCategory.BOREAL_FOREST,
|
180
|
+
EcoClimateZone.BOREAL_DRY: _FuelCategory.BOREAL_FOREST,
|
181
|
+
EcoClimateZone.TROPICAL_MONTANE: _FuelCategory.UNKNOWN_TROPICAL_FOREST,
|
182
|
+
EcoClimateZone.TROPICAL_WET: _FuelCategory.UNKNOWN_TROPICAL_FOREST,
|
183
|
+
EcoClimateZone.TROPICAL_MOIST: _FuelCategory.UNKNOWN_TROPICAL_FOREST,
|
184
|
+
EcoClimateZone.TROPICAL_DRY: _FuelCategory.UNKNOWN_TROPICAL_FOREST
|
185
|
+
},
|
186
|
+
BiomassCategory.NATURAL_FOREST: {
|
187
|
+
EcoClimateZone.WARM_TEMPERATE_MOIST: _FuelCategory.TEMPERATE_FOREST,
|
188
|
+
EcoClimateZone.WARM_TEMPERATE_DRY: _FuelCategory.TEMPERATE_FOREST,
|
189
|
+
EcoClimateZone.COOL_TEMPERATE_MOIST: _FuelCategory.TEMPERATE_FOREST,
|
190
|
+
EcoClimateZone.COOL_TEMPERATE_DRY: _FuelCategory.TEMPERATE_FOREST,
|
191
|
+
EcoClimateZone.BOREAL_MOIST: _FuelCategory.BOREAL_FOREST,
|
192
|
+
EcoClimateZone.BOREAL_DRY: _FuelCategory.BOREAL_FOREST,
|
193
|
+
EcoClimateZone.TROPICAL_MONTANE: _FuelCategory.NATURAL_TROPICAL_FOREST,
|
194
|
+
EcoClimateZone.TROPICAL_WET: _FuelCategory.NATURAL_TROPICAL_FOREST,
|
195
|
+
EcoClimateZone.TROPICAL_MOIST: _FuelCategory.NATURAL_TROPICAL_FOREST,
|
196
|
+
EcoClimateZone.TROPICAL_DRY: _FuelCategory.NATURAL_TROPICAL_FOREST
|
197
|
+
},
|
198
|
+
BiomassCategory.PLANTATION_FOREST: {
|
199
|
+
EcoClimateZone.WARM_TEMPERATE_MOIST: _FuelCategory.TEMPERATE_FOREST,
|
200
|
+
EcoClimateZone.WARM_TEMPERATE_DRY: _FuelCategory.TEMPERATE_FOREST,
|
201
|
+
EcoClimateZone.COOL_TEMPERATE_MOIST: _FuelCategory.TEMPERATE_FOREST,
|
202
|
+
EcoClimateZone.COOL_TEMPERATE_DRY: _FuelCategory.TEMPERATE_FOREST,
|
203
|
+
EcoClimateZone.BOREAL_MOIST: _FuelCategory.BOREAL_FOREST,
|
204
|
+
EcoClimateZone.BOREAL_DRY: _FuelCategory.BOREAL_FOREST,
|
205
|
+
EcoClimateZone.TROPICAL_MONTANE: _FuelCategory.TERTIARY_TROPICAL_FOREST,
|
206
|
+
EcoClimateZone.TROPICAL_WET: _FuelCategory.TERTIARY_TROPICAL_FOREST,
|
207
|
+
EcoClimateZone.TROPICAL_MOIST: _FuelCategory.TERTIARY_TROPICAL_FOREST,
|
208
|
+
EcoClimateZone.TROPICAL_DRY: _FuelCategory.TERTIARY_TROPICAL_FOREST
|
209
|
+
}
|
210
|
+
}
|
211
|
+
"""
|
212
|
+
Mapping from IPCC biomass category and eco-climate zone to natural vegetation fuel category.
|
213
|
+
"""
|
214
|
+
|
215
|
+
_FUEL_CATEGORY_TO_EMISSION_CATEGORY = {
|
216
|
+
_FuelCategory.BOREAL_FOREST: _EmissionCategory.OTHER_FOREST,
|
217
|
+
_FuelCategory.EUCALYPT_FOREST: _EmissionCategory.OTHER_FOREST,
|
218
|
+
_FuelCategory.NATURAL_TROPICAL_FOREST: _EmissionCategory.TROPICAL_FOREST,
|
219
|
+
_FuelCategory.PRIMARY_TROPICAL_FOREST: _EmissionCategory.TROPICAL_FOREST,
|
220
|
+
_FuelCategory.SAVANNA_GRASSLAND_EARLY_DRY_SEASON_BURNS: _EmissionCategory.SAVANNA_AND_GRASSLAND,
|
221
|
+
_FuelCategory.SAVANNA_GRASSLAND_MID_TO_LATE_DRY_SEASON_BURNS: _EmissionCategory.SAVANNA_AND_GRASSLAND,
|
222
|
+
_FuelCategory.SAVANNA_WOODLAND_EARLY_DRY_SEASON_BURNS: _EmissionCategory.SAVANNA_AND_GRASSLAND,
|
223
|
+
_FuelCategory.SAVANNA_WOODLAND_MID_TO_LATE_DRY_SEASON_BURNS: _EmissionCategory.SAVANNA_AND_GRASSLAND,
|
224
|
+
_FuelCategory.SECONDARY_TROPICAL_FOREST: _EmissionCategory.TROPICAL_FOREST,
|
225
|
+
_FuelCategory.SHRUBLAND: _EmissionCategory.SAVANNA_AND_GRASSLAND,
|
226
|
+
_FuelCategory.TEMPERATE_FOREST: _EmissionCategory.OTHER_FOREST,
|
227
|
+
_FuelCategory.TERTIARY_TROPICAL_FOREST: _EmissionCategory.TROPICAL_FOREST,
|
228
|
+
_FuelCategory.UNKNOWN_TROPICAL_FOREST: _EmissionCategory.TROPICAL_FOREST
|
229
|
+
}
|
230
|
+
"""
|
231
|
+
Mapping from natural vegetation fuel category to natural vegetation burning emission category.
|
232
|
+
"""
|
233
|
+
|
234
|
+
|
235
|
+
def _get_fuel_category(biomass_category: BiomassCategory, eco_climate_zone: EcoClimateZone) -> _FuelCategory:
|
236
|
+
"""
|
237
|
+
Get the IPCC (2019) natural vegetation fuel category that corresponds to a specific combination of biomass category
|
238
|
+
and eco-climate zone.
|
239
|
+
"""
|
240
|
+
return _BIOMASS_CATEGORY_TO_FUEL_CATEGORY.get(biomass_category, {}).get(eco_climate_zone)
|
241
|
+
|
242
|
+
|
243
|
+
def _get_emission_category(fuel_category: _FuelCategory) -> _EmissionCategory:
|
244
|
+
"""
|
245
|
+
Get the IPCC (2019) emission category that corresponds to a fuel category.
|
246
|
+
"""
|
247
|
+
return _FUEL_CATEGORY_TO_EMISSION_CATEGORY.get(fuel_category)
|
248
|
+
|
249
|
+
|
250
|
+
def _sample_truncated_normal(
|
251
|
+
*, iterations: int, value: float, sd: float, seed: Union[int, np.random.Generator, None] = None, **_
|
252
|
+
) -> npt.NDArray:
|
253
|
+
"""
|
254
|
+
Randomly sample a model parameter with a truncated normal distribution. Neither fuel factors nor emission factors
|
255
|
+
can be below 0, so truncated normal sampling used.
|
256
|
+
"""
|
257
|
+
return truncated_normal_1d(shape=(1, iterations), mu=value, sigma=sd, low=0, high=np.inf, seed=seed)
|
258
|
+
|
259
|
+
|
260
|
+
def _sample_constant(*, iterations: int, value: float, **_) -> npt.NDArray:
|
261
|
+
"""Sample a constant model parameter."""
|
262
|
+
return repeat_single(shape=(1, iterations), value=value)
|
263
|
+
|
264
|
+
|
265
|
+
_KWARGS_TO_SAMPLE_FUNC = {
|
266
|
+
# ("value", "se", "n"): _sample_standard_error_normal,
|
267
|
+
("value", "sd"): _sample_truncated_normal,
|
268
|
+
("value",): _sample_constant
|
269
|
+
}
|
270
|
+
"""
|
271
|
+
Mapping from available distribution data to sample function.
|
272
|
+
"""
|
273
|
+
|
274
|
+
|
275
|
+
def _get_sample_func(kwargs: dict) -> Callable:
|
276
|
+
"""
|
277
|
+
Select the correct sample function for a parameter based on the distribution data available. All possible
|
278
|
+
parameters for the model should have, at a minimum, a `value`, meaning that no default function needs to be
|
279
|
+
specified.
|
280
|
+
|
281
|
+
This function has been extracted into it's own method to allow for mocking of sample function.
|
282
|
+
|
283
|
+
Keyword Args
|
284
|
+
------------
|
285
|
+
value : float
|
286
|
+
The distribution mean.
|
287
|
+
sd : float
|
288
|
+
The standard deviation of the distribution.
|
289
|
+
se : float
|
290
|
+
The standard error of the distribution.
|
291
|
+
n : float
|
292
|
+
Sample size.
|
293
|
+
|
294
|
+
Returns
|
295
|
+
-------
|
296
|
+
Callable
|
297
|
+
The sample function for the distribution.
|
298
|
+
"""
|
299
|
+
return next(
|
300
|
+
sample_func for required_kwargs, sample_func in _KWARGS_TO_SAMPLE_FUNC.items()
|
301
|
+
if all(kwarg in kwargs.keys() for kwarg in required_kwargs)
|
302
|
+
)
|
303
|
+
|
304
|
+
|
305
|
+
def _get_fuel_factor(fuel_category: _FuelCategory) -> dict:
|
306
|
+
"""
|
307
|
+
Retrieve distribution data for a specific fuel category.
|
308
|
+
"""
|
309
|
+
LOOKUP_KEY = "ipcc2019FuelCategory_tonnesDryMatterCombustedPerHaBurned"
|
310
|
+
LOOKUP_FILENAME = f"{LOOKUP_KEY}.csv"
|
311
|
+
TARGET_DATA = (
|
312
|
+
"value",
|
313
|
+
# "se", # useless without n data
|
314
|
+
# "n" # TODO: add n data to lookup
|
315
|
+
)
|
316
|
+
|
317
|
+
row = fuel_category.name
|
318
|
+
|
319
|
+
lookup = download_lookup(LOOKUP_FILENAME)
|
320
|
+
|
321
|
+
data = {
|
322
|
+
target: get_table_value(lookup, column_name("FuelCategory"), row, column_name(target))
|
323
|
+
for target in TARGET_DATA
|
324
|
+
}
|
325
|
+
|
326
|
+
for term_id, target in product(EMISSION_TERM_IDS, TARGET_DATA):
|
327
|
+
debugMissingLookup(LOOKUP_FILENAME, "FuelCategory", row, target, data.get(target), model=MODEL, term=term_id)
|
328
|
+
|
329
|
+
return (
|
330
|
+
{k: parsed for k, v in data.items() if (parsed := safe_parse_float(v, None)) is not None} # remove missing
|
331
|
+
or _DEFAULT_FACTOR # if parsed dict empty, return default
|
332
|
+
)
|
333
|
+
|
334
|
+
|
335
|
+
def _get_emission_factor(term_id: _EmissionTermId, emission_category: _EmissionCategory) -> dict:
|
336
|
+
"""
|
337
|
+
Retrieve distribution data for a specific emission and emission category.
|
338
|
+
"""
|
339
|
+
LOOKUP_KEY = "emissionToAirNaturalVegetationBurning_emissionCategory_gEmittedPerKgDryMatterCombusted"
|
340
|
+
LOOKUP_FILENAME = f"{LOOKUP_KEY}.csv"
|
341
|
+
TARGET_DATA = ("value", "sd")
|
342
|
+
|
343
|
+
row = term_id
|
344
|
+
column_root = f"IPCC_2019_G_EMITTED_PER_KG_DRY_MATTER_COMBUSTED_{emission_category.name}"
|
345
|
+
|
346
|
+
lookup = download_lookup(LOOKUP_FILENAME)
|
347
|
+
|
348
|
+
data = {
|
349
|
+
target: get_table_value(lookup, column_name("term.id"), row, column_name(f"{column_root}_{target}"))
|
350
|
+
for target in TARGET_DATA
|
351
|
+
}
|
352
|
+
|
353
|
+
for target in TARGET_DATA:
|
354
|
+
debugMissingLookup(
|
355
|
+
LOOKUP_FILENAME, "term.id", row, f"{column_root}_{target}", data.get(target), model=MODEL, term=term_id
|
356
|
+
)
|
357
|
+
|
358
|
+
return (
|
359
|
+
{k: parsed for k, v in data.items() if (parsed := safe_parse_float(v, None)) is not None} # remove missing
|
360
|
+
or _DEFAULT_FACTOR # if parsed dict empty, return default
|
361
|
+
)
|
362
|
+
|
363
|
+
|
364
|
+
def _sample_fuel_factor(
|
365
|
+
fuel_category: _FuelCategory, *, seed: Union[int, np.random.Generator, None] = None
|
366
|
+
) -> npt.NDArray:
|
367
|
+
"""
|
368
|
+
Generate random samples from a fuel factor's distribution data.
|
369
|
+
"""
|
370
|
+
factor_data = _get_fuel_factor(fuel_category)
|
371
|
+
sample_func = _get_sample_func(factor_data)
|
372
|
+
return sample_func(iterations=_ITERATIONS, seed=seed, **factor_data)
|
373
|
+
|
374
|
+
|
375
|
+
def _sample_emission_factor(
|
376
|
+
term_id: _EmissionTermId,
|
377
|
+
emission_category: _EmissionCategory,
|
378
|
+
*,
|
379
|
+
seed: Union[int, np.random.Generator, None] = None
|
380
|
+
) -> npt.NDArray:
|
381
|
+
"""
|
382
|
+
Generate random samples from an emission factor's distribution data.
|
383
|
+
"""
|
384
|
+
factor_data = _get_emission_factor(term_id, emission_category)
|
385
|
+
sample_func = _get_sample_func(factor_data)
|
386
|
+
return sample_func(iterations=_ITERATIONS, seed=seed, **factor_data)
|
387
|
+
|
388
|
+
|
389
|
+
def _emission(term_id: str, **kwargs) -> dict:
|
390
|
+
"""
|
391
|
+
Build a HESTIA [Emission node](https://www.hestia.earth/schema/Emission) using model output data.
|
392
|
+
"""
|
393
|
+
emission = _new_emission(term_id, MODEL)
|
394
|
+
return emission | {
|
395
|
+
**{k: v for k, v in kwargs.items()},
|
396
|
+
"methodTier": TIER
|
397
|
+
}
|
398
|
+
|
399
|
+
|
400
|
+
def _get_site(cycle: dict) -> dict:
|
401
|
+
"""
|
402
|
+
Get the site data from a [Cycle node](https://www.hestia.earth/schema/Cycle).
|
403
|
+
|
404
|
+
Used as a test utility to mock the 'site' data in during testing.
|
405
|
+
"""
|
406
|
+
return cycle.get("site", {})
|
407
|
+
|
408
|
+
|
409
|
+
def get_percent_burned(site: str):
|
410
|
+
LOOKUP_KEY = "region-percentageAreaBurnedDuringForestClearance"
|
411
|
+
LOOKUP_FILENAME = f"{LOOKUP_KEY}.csv"
|
412
|
+
country_id = site.get("country", {}).get("@id")
|
413
|
+
|
414
|
+
value = get_region_lookup_value(LOOKUP_FILENAME, country_id, LOOKUPS[LOOKUP_KEY])
|
415
|
+
return safe_parse_float(value, _DEFAULT_PERCENT_BURNED)
|
416
|
+
|
417
|
+
|
418
|
+
def _calc_burnt_fuel(area_converted: npt.NDArray, fuel_factor: npt.NDArray, frac_burnt: npt.NDArray) -> npt.NDArray:
|
419
|
+
"""
|
420
|
+
Calculate the amount of fuel burnt during a fire event.
|
421
|
+
|
422
|
+
Parameters
|
423
|
+
----------
|
424
|
+
area_converted : NDArray
|
425
|
+
Area of land converted (ha).
|
426
|
+
fuel_factor : NDArray
|
427
|
+
Conversion factor (kg fuel per ha of land cover converted).
|
428
|
+
frac_burnt : NDArray
|
429
|
+
The fraction of land converted using burning during a land use change event (decimal percentage, 0-1).
|
430
|
+
|
431
|
+
Returns
|
432
|
+
-------
|
433
|
+
NDArray
|
434
|
+
The mass of burnt fuel (kg)
|
435
|
+
"""
|
436
|
+
return area_converted * fuel_factor * frac_burnt
|
437
|
+
|
438
|
+
|
439
|
+
def _build_fuel_burnt_accumulator(
|
440
|
+
percent_burned: npt.ArrayLike,
|
441
|
+
eco_climate_zone: EcoClimateZone,
|
442
|
+
sample_fuel_factor_func: Callable[[_FuelCategory], npt.NDArray]
|
443
|
+
):
|
444
|
+
"""
|
445
|
+
Build an `accumulate_fuel_burnt` function to reduce natural vegetation deltas into mass of fuel burnt per
|
446
|
+
`_FuelCategory`.
|
447
|
+
|
448
|
+
Parameters
|
449
|
+
----------
|
450
|
+
percent_burned : NDArray
|
451
|
+
The percentage of land converted using burning during a land use change event (percentage, 0-100%).
|
452
|
+
eco_climate_zone : EcoClimateZone
|
453
|
+
The eco-climate zone of the Site.
|
454
|
+
sample_fuel_factor_func : Callable[[_FuelCategory], npt.NDArray]
|
455
|
+
Function to sample fuel factor parameter.
|
456
|
+
|
457
|
+
Returns
|
458
|
+
-------
|
459
|
+
NDArray
|
460
|
+
The mass of burnt fuel (kg)
|
461
|
+
"""
|
462
|
+
frac_burnt = percent_burned / 100
|
463
|
+
|
464
|
+
def accumulate_fuel_burnt(
|
465
|
+
result: dict[_FuelCategory, npt.NDArray], biomass_category: BiomassCategory, delta: float
|
466
|
+
) -> dict[_FuelCategory, npt.NDArray]:
|
467
|
+
"""
|
468
|
+
Calculate the amount of fuel burnt when natural vegetation is lost. Accumulate fuel burnt by `_FuelCategory`.
|
469
|
+
|
470
|
+
Parameters
|
471
|
+
----------
|
472
|
+
result : dict[_FuelCategory, npt.NDArray]
|
473
|
+
A dict with the format `{_FuelCategory: kg_fuel_burnt (npt.NDArray)}`.
|
474
|
+
biomass_category : BiomassCategory
|
475
|
+
A biomass category undergoing change during a LUC event.
|
476
|
+
delta : float
|
477
|
+
The change in land cover for the biomass category (% area).
|
478
|
+
|
479
|
+
Returns
|
480
|
+
-------
|
481
|
+
dict[_FuelCategory, npt.NDArray]
|
482
|
+
"""
|
483
|
+
|
484
|
+
fuel_category = _get_fuel_category(biomass_category, eco_climate_zone)
|
485
|
+
fuel_factor = sample_fuel_factor_func(fuel_category)
|
486
|
+
|
487
|
+
area_converted = abs(delta) / 100 if delta < 0 else 0 # We only care about losses
|
488
|
+
|
489
|
+
already_burnt = result.get(fuel_category, np.array(0))
|
490
|
+
|
491
|
+
update_dict = {} if area_converted == 0 else {
|
492
|
+
fuel_category: already_burnt + _calc_burnt_fuel(area_converted, fuel_factor, frac_burnt)
|
493
|
+
}
|
494
|
+
|
495
|
+
return result | update_dict
|
496
|
+
|
497
|
+
return accumulate_fuel_burnt
|
498
|
+
|
499
|
+
|
500
|
+
def _calc_emission(fuel_burnt: npt.NDArray, emission_factor: npt.NDArray,) -> npt.NDArray:
|
501
|
+
"""
|
502
|
+
Calculate the emission from a fuel burning.
|
503
|
+
|
504
|
+
Parameters
|
505
|
+
----------
|
506
|
+
fuel_burnt : NDArray
|
507
|
+
The mass of burnt fuel (kg).
|
508
|
+
emission_factor : NDArray
|
509
|
+
Conversion factor (kg emission per kg of fuel burnt).
|
510
|
+
|
511
|
+
Returns
|
512
|
+
-------
|
513
|
+
NDArray
|
514
|
+
The mass of emission (kg)
|
515
|
+
"""
|
516
|
+
return fuel_burnt * emission_factor
|
517
|
+
|
518
|
+
|
519
|
+
def _sum_cycle_emissions(term_id: _EmissionTermId, cycle_id: str, inventory: _Inventory) -> npt.NDArray:
|
520
|
+
"""
|
521
|
+
Sum the emissions allocated to a cycle.
|
522
|
+
"""
|
523
|
+
KEY = "allocated_emissions"
|
524
|
+
|
525
|
+
def add_cycle_emissions(result: npt.NDArray, year: int) -> npt.NDArray:
|
526
|
+
allocated_emissions = inventory.get(year, {}).get(KEY, {}).get(term_id, {})
|
527
|
+
return result + allocated_emissions.get(cycle_id, np.array(0))
|
528
|
+
|
529
|
+
return reduce(add_cycle_emissions, inventory.keys(), np.array(0))
|
530
|
+
|
531
|
+
|
532
|
+
def _compile_run_data(
|
533
|
+
cycle: dict, site: dict, land_cover_nodes: list[dict], eco_climate_zone: EcoClimateZone
|
534
|
+
) -> tuple[_EmissionInventory, _Inventory, dict]:
|
535
|
+
"""
|
536
|
+
Compile the run data for the model, collating data from `site.management` and related cycles. An annualised
|
537
|
+
inventory of land cover change and natural vegetation burning events is constructed. Emissions from burning events
|
538
|
+
are estimated, amortised over 20 years and allocated to cycles.
|
539
|
+
|
540
|
+
Parameters
|
541
|
+
----------
|
542
|
+
cycle : dict
|
543
|
+
The HESTIA [Cycle](https://www.hestia.earth/schema/Cycle) the model is running on.
|
544
|
+
site : dict
|
545
|
+
The HESTIA [Site](https://www.hestia.earth/schema/Site) the Cycle takes place on.
|
546
|
+
land_cover_nodes : list[dict]
|
547
|
+
Valid land cover [Management nodes](https://www.hestia.earth/schema/Management) extracted from the Site.
|
548
|
+
eco_climate_zone : EcoClimateZone
|
549
|
+
The eco-climate zone of the Site.
|
550
|
+
|
551
|
+
Returns
|
552
|
+
-------
|
553
|
+
emission_inventory : _EmissionInventory
|
554
|
+
A dictionary of emissions relevant to the cycle the model is run on, in the format:
|
555
|
+
```
|
556
|
+
{
|
557
|
+
emission_term_id (str): value (NDArray),
|
558
|
+
...
|
559
|
+
}
|
560
|
+
```
|
561
|
+
inventory : _Inventory
|
562
|
+
An inventory of model data
|
563
|
+
logs : dict
|
564
|
+
Data from the compilation process that should be logged.
|
565
|
+
"""
|
566
|
+
cycle_id = cycle.get("@id")
|
567
|
+
related_cycles_ = related_cycles(site, cycles_mapping={cycle_id: cycle})
|
568
|
+
|
569
|
+
seed = gen_seed(site, MODEL, TERM_ID)
|
570
|
+
rng = np.random.default_rng(seed)
|
571
|
+
|
572
|
+
cycles_grouped = group_nodes_by_year(related_cycles_)
|
573
|
+
land_cover_grouped = group_nodes_by_year(land_cover_nodes)
|
574
|
+
percent_burned = get_percent_burned(site)
|
575
|
+
|
576
|
+
@lru_cache(maxsize=len(_FuelCategory))
|
577
|
+
def sample_fuel_factor(*args):
|
578
|
+
"""Fuel factors should not be re-sampled between years, so cache results."""
|
579
|
+
return _sample_fuel_factor(*args, seed=rng)
|
580
|
+
|
581
|
+
@lru_cache(maxsize=len(EMISSION_TERM_IDS)*len(_EmissionCategory))
|
582
|
+
def sample_emission_factor(*args):
|
583
|
+
"""Emission factors should not be re-sampled between years, so cache results."""
|
584
|
+
return _sample_emission_factor(*args, seed=rng)
|
585
|
+
|
586
|
+
accumulate_fuel_burnt = _build_fuel_burnt_accumulator(percent_burned, eco_climate_zone, sample_fuel_factor)
|
587
|
+
|
588
|
+
def build_inventory_year(inventory: _Inventory, year: int) -> dict:
|
589
|
+
"""
|
590
|
+
Parameters
|
591
|
+
----------
|
592
|
+
inventory : _Inventory
|
593
|
+
An inventory of model data.
|
594
|
+
year : int
|
595
|
+
The year of the inventory to build.
|
596
|
+
|
597
|
+
Returns
|
598
|
+
-------
|
599
|
+
inventory : dict
|
600
|
+
An inventory of model data, updated to include the new model year.
|
601
|
+
"""
|
602
|
+
land_cover_nodes = next((nodes for year_, nodes in land_cover_grouped.items() if year_ >= year), []) # Backfill
|
603
|
+
|
604
|
+
biomass_category_summary = summarise_land_cover_nodes(land_cover_nodes)
|
605
|
+
prev_biomass_category_summary = inventory.get(year-1, {}).get("biomass_category_summary", {})
|
606
|
+
|
607
|
+
natural_vegetation_delta = {
|
608
|
+
category: biomass_category_summary.get(category, 0) - prev_biomass_category_summary.get(category, 0)
|
609
|
+
for category in _NATURAL_VEGETATION_CATEGORIES
|
610
|
+
}
|
611
|
+
|
612
|
+
fuel_burnt_per_category = reduce(
|
613
|
+
lambda result, item: accumulate_fuel_burnt(result, *item),
|
614
|
+
natural_vegetation_delta.items(),
|
615
|
+
dict()
|
616
|
+
)
|
617
|
+
|
618
|
+
annual_emissions = {
|
619
|
+
term_id: sum(
|
620
|
+
_calc_emission(amount, sample_emission_factor(term_id, _get_emission_category(fuel_category)))
|
621
|
+
for fuel_category, amount in fuel_burnt_per_category.items()
|
622
|
+
) for term_id in EMISSION_TERM_IDS
|
623
|
+
}
|
624
|
+
|
625
|
+
previous_years = list(inventory.keys())
|
626
|
+
amortisation_slice_index = max(0, len(previous_years) - (_AMORTISATION_PERIOD - 1))
|
627
|
+
amortisation_years = previous_years[amortisation_slice_index:] # get the previous 19 years, if available
|
628
|
+
|
629
|
+
amortised_emissions = {
|
630
|
+
term_id: 0.05 * (
|
631
|
+
annual_emissions[term_id] + sum(
|
632
|
+
inventory[year_]["annual_emissions"][term_id] for year_ in amortisation_years
|
633
|
+
)
|
634
|
+
) for term_id in EMISSION_TERM_IDS
|
635
|
+
}
|
636
|
+
|
637
|
+
cycles = cycles_grouped.get(year, [])
|
638
|
+
total_cycle_duration = sum(c.get("fraction_of_group_duration", 0) for c in cycles)
|
639
|
+
|
640
|
+
share_of_emissions = {
|
641
|
+
cycle["@id"]: cycle.get("fraction_of_group_duration", 0) / total_cycle_duration
|
642
|
+
for cycle in cycles
|
643
|
+
}
|
644
|
+
|
645
|
+
allocated_emissions = {
|
646
|
+
term_id: {
|
647
|
+
cycle_id: share_of_emission * amortised_emissions[term_id]
|
648
|
+
for cycle_id, share_of_emission in share_of_emissions.items()
|
649
|
+
}
|
650
|
+
for term_id in EMISSION_TERM_IDS
|
651
|
+
}
|
652
|
+
|
653
|
+
inventory[year] = _InventoryYear(
|
654
|
+
biomass_category_summary=biomass_category_summary,
|
655
|
+
natural_vegetation_delta=natural_vegetation_delta,
|
656
|
+
fuel_burnt_per_category=fuel_burnt_per_category,
|
657
|
+
annual_emissions=annual_emissions,
|
658
|
+
amortised_emissions=amortised_emissions,
|
659
|
+
share_of_emissions=share_of_emissions,
|
660
|
+
allocated_emissions=allocated_emissions
|
661
|
+
)
|
662
|
+
|
663
|
+
return inventory
|
664
|
+
|
665
|
+
all_years = list(cycles_grouped.keys()) + list(land_cover_grouped.keys())
|
666
|
+
min_year, max_year = min(all_years), max(all_years)
|
667
|
+
|
668
|
+
inventory = reduce(build_inventory_year, range(min_year, max_year+1), dict())
|
669
|
+
|
670
|
+
emission_inventory = {
|
671
|
+
term_id: value for term_id in EMISSION_TERM_IDS
|
672
|
+
if np.all((value := _sum_cycle_emissions(term_id, cycle_id, inventory)) > 0)
|
673
|
+
}
|
674
|
+
|
675
|
+
logs = {
|
676
|
+
"percent_burned": percent_burned,
|
677
|
+
"seed": seed,
|
678
|
+
}
|
679
|
+
|
680
|
+
return emission_inventory, inventory, logs
|
681
|
+
|
682
|
+
|
683
|
+
def _format_bool(value: Optional[bool]) -> str:
|
684
|
+
"""Format a bool for logging in a table."""
|
685
|
+
return str(bool(value))
|
686
|
+
|
687
|
+
|
688
|
+
def _format_number(value: Optional[float], unit: Optional[str] = None) -> str:
|
689
|
+
"""Format a float for logging in a table."""
|
690
|
+
return f"{value:.1f}{f' {unit}' if unit else ''}" if isinstance(value, (float, int)) else "None"
|
691
|
+
|
692
|
+
|
693
|
+
def _format_nd_array(value: Optional[npt.NDArray], unit: Optional[str] = None) -> str:
|
694
|
+
"""Format a numpy array for logging in a table."""
|
695
|
+
return (
|
696
|
+
f"{_format_number(value.mean())} ± {_format_number(value.std())}" + f"{f' {unit}' if unit else ''}"
|
697
|
+
if isinstance(value, np.ndarray) else "None"
|
698
|
+
)
|
699
|
+
|
700
|
+
|
701
|
+
def _format_decimal_percentage(value: Optional[float], unit: Optional[str] = "pct") -> str:
|
702
|
+
"""Format a decimal percentage (0-1) as a percentage (0-100%) for logging in a table."""
|
703
|
+
return _format_number(value * 100, unit) if isinstance(value, (float, int)) else "None"
|
704
|
+
|
705
|
+
|
706
|
+
_INVALID_CHARS = {"_", ":", ",", "="}
|
707
|
+
_REPLACEMENT_CHAR = "-"
|
708
|
+
|
709
|
+
|
710
|
+
def _format_str(value: str, *_) -> str:
|
711
|
+
"""Format a string for logging in a table. Remove all characters used to render the table on the front end."""
|
712
|
+
return reduce(lambda x, char: x.replace(char, _REPLACEMENT_CHAR), _INVALID_CHARS, str(value))
|
713
|
+
|
714
|
+
|
715
|
+
def _format_column_header(*keys: tuple[Union[Enum, str], ...]) -> str:
|
716
|
+
"""Format a variable number of enums and strings for logging as a table column header."""
|
717
|
+
return " ".join(_format_str(k.value if isinstance(k, Enum) else str(k)) for k in keys)
|
718
|
+
|
719
|
+
|
720
|
+
def _format_eco_climate_zone(value: EcoClimateZone) -> str:
|
721
|
+
"""Format an eco-climate zone for logging."""
|
722
|
+
return (
|
723
|
+
_format_str(str(value.name).lower().replace("_", " ").capitalize()) if isinstance(value, EcoClimateZone)
|
724
|
+
else "None"
|
725
|
+
)
|
726
|
+
|
727
|
+
|
728
|
+
_LOGS_FORMAT_DATA: dict[str, Callable] = {
|
729
|
+
"has_valid_site_type": _format_bool,
|
730
|
+
"eco_climate_zone": _format_eco_climate_zone,
|
731
|
+
"has_valid_eco_climate_zone": _format_bool,
|
732
|
+
"has_land_cover_nodes": _format_bool,
|
733
|
+
"should_compile_inventory": _format_bool,
|
734
|
+
"percent_burned": lambda x: _format_number(x, "pct"),
|
735
|
+
}
|
736
|
+
_DEFAULT_FORMAT_FUNC = _format_str
|
737
|
+
|
738
|
+
|
739
|
+
def _format_logs(logs: dict) -> dict[str, str]:
|
740
|
+
"""
|
741
|
+
Format model logs - excluding the inventory data, which must be formatted separately.
|
742
|
+
"""
|
743
|
+
return {key: _LOGS_FORMAT_DATA.get(key, _DEFAULT_FORMAT_FUNC)(value) for key, value in logs.items()}
|
744
|
+
|
745
|
+
|
746
|
+
_INVENTORY_FORMAT_DATA: dict[_InventoryKey, dict[Literal["filter_by", "format_func"], Any]] = {
|
747
|
+
"fuel_burnt_per_category": {
|
748
|
+
"format_func": lambda x: _format_nd_array(x, "kg")
|
749
|
+
},
|
750
|
+
"annual_emissions": {
|
751
|
+
"filter_by": ("term_id", ),
|
752
|
+
"format_func": lambda x: _format_nd_array(x, "kg")
|
753
|
+
},
|
754
|
+
"amortised_emissions": {
|
755
|
+
"filter_by": ("term_id", ),
|
756
|
+
"format_func": lambda x: _format_nd_array(x, "kg")
|
757
|
+
},
|
758
|
+
"share_of_emissions": {
|
759
|
+
"filter_by": ("cycle_id", ),
|
760
|
+
"format_func": _format_decimal_percentage
|
761
|
+
},
|
762
|
+
"allocated_emissions": {
|
763
|
+
"filter_by": ("term_id", "cycle_id"),
|
764
|
+
"format_func": lambda x: _format_nd_array(x, "kg")
|
765
|
+
}
|
766
|
+
}
|
767
|
+
"""
|
768
|
+
Mapping between inventory key and formatting options for logging in a table. Inventory keys not included in the dict
|
769
|
+
will not be logged in the table.
|
770
|
+
"""
|
771
|
+
|
772
|
+
|
773
|
+
def _flatten_dict(d: dict) -> dict[tuple, Any]:
|
774
|
+
"""
|
775
|
+
Flatten a nested dict, returns dict with keys as tuples with format `(key_level_1, key_level_2, ..., key_level_n)`.
|
776
|
+
"""
|
777
|
+
def flatten(d: dict, c: Optional[list] = None):
|
778
|
+
c = c or []
|
779
|
+
for a, b in d.items():
|
780
|
+
if not isinstance(b, dict):
|
781
|
+
yield (tuple(c+[a]), b)
|
782
|
+
else:
|
783
|
+
yield from flatten(b, c+[a])
|
784
|
+
|
785
|
+
return dict(flatten(d))
|
786
|
+
|
787
|
+
|
788
|
+
def _get_relevant_inner_keys(
|
789
|
+
term_id: _EmissionTermId,
|
790
|
+
cycle_id: str,
|
791
|
+
key: str,
|
792
|
+
inventory: _Inventory,
|
793
|
+
*,
|
794
|
+
filter_by: Optional[tuple[Literal["term_id", "cycle_id"], ...]] = None,
|
795
|
+
**_
|
796
|
+
) -> list[tuple]:
|
797
|
+
"""
|
798
|
+
Get the column headings for the formatted table. Nested inventory values should be flattened, with nested keys
|
799
|
+
being transformed into a tuple with the format `(key_level_1, key_level_2, ..., key_level_n)`.
|
800
|
+
|
801
|
+
Inner keys not relevant to the emission term being logged or the cycle the model is running on should be excluded.
|
802
|
+
"""
|
803
|
+
FILTER_VALUES = {"term_id": term_id, "cycle_id": cycle_id}
|
804
|
+
filter_target = (
|
805
|
+
tuple(val for f in filter_by if (val := FILTER_VALUES.get(f)))
|
806
|
+
if filter_by else None
|
807
|
+
)
|
808
|
+
|
809
|
+
inner_keys = {
|
810
|
+
tuple(k) for inner in inventory.values() for k in _flatten_dict(inner.get(key, {}))
|
811
|
+
if not filter_target or k == filter_target
|
812
|
+
}
|
813
|
+
|
814
|
+
return sorted(
|
815
|
+
inner_keys,
|
816
|
+
key=lambda category: category.value if isinstance(category, Enum) else str(category)
|
817
|
+
)
|
818
|
+
|
819
|
+
|
820
|
+
def _format_inventory(term_id: _EmissionTermId, cycle_id: str, inventory: dict) -> str:
|
821
|
+
"""
|
822
|
+
Format the inventory for logging as a table.
|
823
|
+
|
824
|
+
Extract relevant data, flatten nested dicts and format inventory values based on expected data type.
|
825
|
+
"""
|
826
|
+
relevant_inventory_keys = {
|
827
|
+
inventory_key: _get_relevant_inner_keys(term_id, cycle_id, inventory_key, inventory, **kwargs)
|
828
|
+
for inventory_key, kwargs in _INVENTORY_FORMAT_DATA.items()
|
829
|
+
}
|
830
|
+
|
831
|
+
return log_as_table(
|
832
|
+
{
|
833
|
+
"year": year,
|
834
|
+
**{
|
835
|
+
_format_column_header(inventory_key, *inner_key): _INVENTORY_FORMAT_DATA[inventory_key]["format_func"](
|
836
|
+
reduce(lambda d, k: d.get(k, {}), [year, inventory_key, *inner_key], inventory)
|
837
|
+
)
|
838
|
+
for inventory_key, relevant_inner_keys in relevant_inventory_keys.items()
|
839
|
+
for inner_key in relevant_inner_keys
|
840
|
+
}
|
841
|
+
} for year in inventory
|
842
|
+
) if inventory else "None"
|
843
|
+
|
844
|
+
|
845
|
+
def _should_run_emission(
|
846
|
+
term_id: _EmissionTermId, cycle: dict, emission_inventory: _EmissionInventory, inventory: dict, logs: dict
|
847
|
+
):
|
848
|
+
"""
|
849
|
+
Determine, based on the compiled data, whether the model should run for a specifc emission term id. Format and log
|
850
|
+
the model logs and inventory.
|
851
|
+
"""
|
852
|
+
should_run = term_id in emission_inventory
|
853
|
+
|
854
|
+
formatted_logs = _format_logs(logs)
|
855
|
+
formatted_inventory = _format_inventory(term_id, cycle.get("@id"), inventory)
|
856
|
+
|
857
|
+
logRequirements(cycle, model=MODEL, term=term_id, **formatted_logs, inventory=formatted_inventory)
|
858
|
+
logShouldRun(cycle, MODEL, term_id, should_run)
|
859
|
+
|
860
|
+
return should_run
|
861
|
+
|
862
|
+
|
863
|
+
def _should_run(cycle: dict):
|
864
|
+
"""
|
865
|
+
Extract, organise and pre-process required data from the input [Cycle node](https://www.hestia.earth/schema/Site)
|
866
|
+
and determine whether the model should run.
|
867
|
+
|
868
|
+
Parameters
|
869
|
+
----------
|
870
|
+
cycle : dict
|
871
|
+
A HESTIA [Cycle](https://www.hestia.earth/schema/Cycle).
|
872
|
+
|
873
|
+
Returns
|
874
|
+
-------
|
875
|
+
tuple[bool, dict]
|
876
|
+
should_run, emission_inventory
|
877
|
+
"""
|
878
|
+
site = _get_site(cycle)
|
879
|
+
|
880
|
+
site_type = site.get("siteType")
|
881
|
+
eco_climate_zone = get_eco_climate_zone_value(site, as_enum=True)
|
882
|
+
|
883
|
+
land_cover_nodes = get_valid_management_nodes(site)
|
884
|
+
|
885
|
+
has_valid_site_type = all([site_type, site_type not in _EXCLUDED_SITE_TYPES])
|
886
|
+
has_valid_eco_climate_zone = all([eco_climate_zone, eco_climate_zone not in _EXCLUDED_ECO_CLIMATE_ZONES])
|
887
|
+
has_land_cover_nodes = len(land_cover_nodes) > 0
|
888
|
+
|
889
|
+
should_compile_inventory = all([
|
890
|
+
has_valid_site_type,
|
891
|
+
has_valid_eco_climate_zone,
|
892
|
+
has_land_cover_nodes
|
893
|
+
])
|
894
|
+
|
895
|
+
emission_inventory, inventory, compilation_logs = (
|
896
|
+
_compile_run_data(cycle, site, land_cover_nodes, eco_climate_zone)
|
897
|
+
if should_compile_inventory else ({}, {}, {})
|
898
|
+
)
|
899
|
+
|
900
|
+
logs = {
|
901
|
+
"site_id": site.get("@id"),
|
902
|
+
"site_type": site_type,
|
903
|
+
"has_valid_site_type": has_valid_site_type,
|
904
|
+
"eco_climate_zone": eco_climate_zone,
|
905
|
+
"has_valid_eco_climate_zone": has_valid_eco_climate_zone,
|
906
|
+
"has_land_cover_nodes": has_land_cover_nodes,
|
907
|
+
"should_compile_inventory": should_compile_inventory,
|
908
|
+
**compilation_logs
|
909
|
+
}
|
910
|
+
|
911
|
+
should_run = all([
|
912
|
+
any([
|
913
|
+
_should_run_emission(term_id, cycle, emission_inventory, inventory, logs) for term_id in EMISSION_TERM_IDS
|
914
|
+
])
|
915
|
+
])
|
916
|
+
|
917
|
+
return should_run, emission_inventory
|
918
|
+
|
919
|
+
|
920
|
+
def _run_emission(term_id: _EmissionTermId, emissions: dict[_EmissionTermId, npt.NDArray]) -> list[dict]:
|
921
|
+
"""
|
922
|
+
Retrieve the pre-computed emissions and format them as a HESTIA
|
923
|
+
[Emission node](https://www.hestia.earth/schema/Emission).
|
924
|
+
"""
|
925
|
+
emission = emissions[term_id]
|
926
|
+
descriptive_stats = calc_descriptive_stats(emission, STATS_DEFINITION, decimals=3)
|
927
|
+
return _emission(term_id, **descriptive_stats)
|
928
|
+
|
929
|
+
|
930
|
+
def run(cycle: dict):
|
931
|
+
"""
|
932
|
+
Run the `nonCo2EmissionsToAirNaturalVegetationBurning` model on a Cycle.
|
933
|
+
|
934
|
+
Parameters
|
935
|
+
----------
|
936
|
+
cycle : dict
|
937
|
+
A HESTIA [Cycle](https://www.hestia.earth/schema/Cycle).
|
938
|
+
|
939
|
+
Returns
|
940
|
+
-------
|
941
|
+
list[dict]
|
942
|
+
A list of HESTIA [Emission](https://www.hestia.earth/schema/Emission) nodes with `term.termType` =
|
943
|
+
`ch4ToAirNaturalVegetationBurning` **OR** `coToAirNaturalVegetationBurning` **OR**
|
944
|
+
`n2OToAirNaturalVegetationBurningDirect` **OR** `noxToAirNaturalVegetationBurning`.
|
945
|
+
"""
|
946
|
+
should_run, emission_inventory = _should_run(cycle)
|
947
|
+
return [_run_emission(term_id, emission_inventory) for term_id in emission_inventory] if should_run else []
|