hestia-earth-models 0.64.11__py3-none-any.whl → 0.64.12__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 (25) hide show
  1. hestia_earth/models/cycle/concentrateFeed.py +31 -19
  2. hestia_earth/models/faostat2018/utils.py +72 -12
  3. hestia_earth/models/hestia/__init__.py +13 -0
  4. hestia_earth/models/hestia/landCover.py +725 -0
  5. hestia_earth/models/ipcc2019/animal/fatContent.py +1 -1
  6. hestia_earth/models/ipcc2019/animal/milkYieldPerAnimal.py +91 -0
  7. hestia_earth/models/ipcc2019/animal/trueProteinContent.py +1 -1
  8. hestia_earth/models/ipcc2019/animal/utils.py +17 -12
  9. hestia_earth/models/ipcc2019/co2ToAirAboveGroundBiomassStockChange.py +8 -4
  10. hestia_earth/models/ipcc2019/co2ToAirBelowGroundBiomassStockChange.py +7 -3
  11. hestia_earth/models/ipcc2019/co2ToAirCarbonStockChange_utils.py +45 -3
  12. hestia_earth/models/ipcc2019/co2ToAirSoilOrganicCarbonStockChange.py +7 -3
  13. hestia_earth/models/mocking/search-results.json +914 -914
  14. hestia_earth/models/utils/lookup.py +2 -1
  15. hestia_earth/models/version.py +1 -1
  16. {hestia_earth_models-0.64.11.dist-info → hestia_earth_models-0.64.12.dist-info}/METADATA +1 -1
  17. {hestia_earth_models-0.64.11.dist-info → hestia_earth_models-0.64.12.dist-info}/RECORD +25 -18
  18. tests/models/faostat2018/test_faostat_utils.py +84 -0
  19. tests/models/hestia/__init__.py +0 -0
  20. tests/models/hestia/test_landCover.py +209 -0
  21. tests/models/ipcc2019/animal/test_milkYieldPerAnimal.py +21 -0
  22. tests/models/ipcc2019/test_co2ToAirSoilOrganicCarbonStockChange.py +48 -1
  23. {hestia_earth_models-0.64.11.dist-info → hestia_earth_models-0.64.12.dist-info}/LICENSE +0 -0
  24. {hestia_earth_models-0.64.11.dist-info → hestia_earth_models-0.64.12.dist-info}/WHEEL +0 -0
  25. {hestia_earth_models-0.64.11.dist-info → hestia_earth_models-0.64.12.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,725 @@
1
+ """
2
+ Land Cover
3
+
4
+ This model calculates historic land use change over a twenty-year period, extending the
5
+ functionality of the Blonk model.
6
+ """
7
+ import math
8
+ from collections import defaultdict
9
+
10
+ from hestia_earth.schema import SiteSiteType, TermTermType
11
+ from hestia_earth.utils.lookup import (download_lookup, get_table_value, column_name,
12
+ extract_grouped_data_closest_date, _is_missing_value, extract_grouped_data)
13
+ from hestia_earth.utils.model import filter_list_term_type
14
+ from hestia_earth.utils.tools import safe_parse_float, to_precision, non_empty_value
15
+
16
+ from hestia_earth.models.cycle.input.hestiaAggregatedData import MODEL_ID
17
+ from hestia_earth.models.log import logRequirements, log_as_table, logShouldRun
18
+ from hestia_earth.models.utils.term import get_lookup_value
19
+ from hestia_earth.models.hestia import MODEL
20
+
21
+ REQUIREMENTS = {
22
+ "Site": {
23
+ "siteType": [
24
+ "forest",
25
+ "cropland",
26
+ "permanent pasture",
27
+ "other natural vegetation"
28
+ ],
29
+ "country": "",
30
+ "management": [
31
+ {
32
+ "@type": "Management",
33
+ "value": "",
34
+ "term.termType": "landCover",
35
+ "endDate": ""
36
+ }
37
+ ]
38
+ }
39
+ }
40
+ RETURNS = {
41
+ "Management": [{
42
+ "@type": "Management",
43
+ "term.termType": "landCover",
44
+ "term.@id": [
45
+ "Forest", "Annual cropland", "Permanent cropland", "Permanent pasture", "Other natural vegetation"
46
+ ],
47
+ "value": "",
48
+ "endDate": "",
49
+ "startDate": "",
50
+ "methodModel": "HESTIA",
51
+ "methodClassification": ["modelled using other measurements", "expert opinion"]
52
+ }]
53
+ }
54
+ LOOKUPS = {
55
+ "region-crop-cropGroupingFaostatProduction-areaHarvestedUpTo20YearExpansion": "All crops",
56
+ "region-crop-cropGroupingFaostatProduction-areaHarvested": "All crops",
57
+ "region-faostatArea-UpTo20YearExpansion": "All land uses",
58
+ "region-faostatArea": [
59
+ "Arable land",
60
+ "Cropland",
61
+ "Forest land",
62
+ "Land area",
63
+ "Other land",
64
+ "Permanent crops",
65
+ "Permanent meadows and pastures"
66
+ ],
67
+ "crop": ["cropGroupingFaostatArea", "IPCC_LAND_USE_CATEGORY"],
68
+ "landCover": ["cropGroupingFaostatProduction", "FAOSTAT_LAND_AREA_CATEGORY"]
69
+ }
70
+ MODEL_KEY = "landCover"
71
+ LAND_AREA = LOOKUPS["region-faostatArea"][3]
72
+ TOTAL_CROPLAND = "Cropland"
73
+ ANNUAL_CROPLAND = "Arable land"
74
+ FOREST_LAND = "Forest land"
75
+ OTHER_LAND = "Other land"
76
+ PERMANENT_CROPLAND = "Permanent crops"
77
+ PERMANENT_PASTURE = "Permanent meadows and pastures"
78
+ TOTAL_AGRICULTURAL_CHANGE = "Total agricultural change"
79
+ ALL_LAND_USE_TERMS = [
80
+ FOREST_LAND,
81
+ TOTAL_CROPLAND,
82
+ ANNUAL_CROPLAND,
83
+ PERMANENT_CROPLAND,
84
+ PERMANENT_PASTURE,
85
+ OTHER_LAND
86
+ ]
87
+ # Mapping from Land use terms to Management node terms.
88
+ # land use term: (@id, name)
89
+ LAND_USE_TERMS_FOR_TRANSFORMATION = {
90
+ FOREST_LAND: ("forest", "Forest"),
91
+ ANNUAL_CROPLAND: ("annualCropland", "Annual cropland"),
92
+ PERMANENT_CROPLAND: ("permanentCropland", "Permanent cropland"),
93
+ PERMANENT_PASTURE: ("permanentPasture", "Permanent pasture"),
94
+ OTHER_LAND: ("otherLand", OTHER_LAND) # Not used yet
95
+ }
96
+ SITE_TYPES = {
97
+ SiteSiteType.CROPLAND.value,
98
+ SiteSiteType.FOREST.value,
99
+ SiteSiteType.OTHER_NATURAL_VEGETATION.value,
100
+ SiteSiteType.PERMANENT_PASTURE.value
101
+ }
102
+ DEFAULT_WINDOW_IN_YEARS = 20
103
+ IPCC_LAND_USE_CATEGORY_ANNUAL = "Annual crops"
104
+ IPCC_LAND_USE_CATEGORY_PERENNIAL = "Perennial crops"
105
+ OUTPUT_SIGNIFICANT_DIGITS = 3
106
+
107
+
108
+ def _is_missing_or_none(value) -> bool:
109
+ return value is None or _is_missing_value(value)
110
+
111
+
112
+ def _safe_divide(numerator, denominator, default=0) -> float:
113
+ return default if denominator == 0 else numerator / denominator
114
+
115
+
116
+ def site_area_sum_to_100(dict_of_percentages: dict):
117
+ return False if dict_of_percentages == {} else \
118
+ (math.isclose(sum(dict_of_percentages.values()), 1.0, rel_tol=0.01) or
119
+ math.isclose(sum(dict_of_percentages.values()), 0.0, rel_tol=0.01))
120
+
121
+
122
+ def _lookup_land_use_type(nodes: list) -> str:
123
+ """Look up the land use type from a management node."""
124
+ return "" if nodes == [] else get_lookup_value(
125
+ lookup_term=nodes[0].get("term", {}),
126
+ column=LOOKUPS.get("landCover")[1],
127
+ model=MODEL,
128
+ term=nodes[0].get("term", {})
129
+ )
130
+
131
+
132
+ def _crop_ipcc_land_use_category(
133
+ crop_term_id: str,
134
+ lookup_term_type: str = TermTermType.LANDCOVER.value
135
+ ) -> str:
136
+ """
137
+ Looks up the crop in the lookup.
138
+ Returns the IPCC_LAND_USE_CATEGORY.
139
+ """
140
+ return get_lookup_value(
141
+ lookup_term={"@id": crop_term_id, "type": "Term", "termType": lookup_term_type},
142
+ column=LOOKUPS.get("crop")[1],
143
+ model=MODEL,
144
+ term={"@id": crop_term_id, "type": "Term", "termType": lookup_term_type}
145
+ )
146
+
147
+
148
+ def get_changes(country_id: str, end_year: int) -> dict:
149
+ """
150
+ For each entry in ALL_LAND_USE_TERMS, creates a key: value in output dictionary, also TOTAL
151
+ """
152
+ lookup = download_lookup("region-faostatArea-UpTo20YearExpansion.csv")
153
+ changes_dict = {
154
+ land_use_term: safe_parse_float(
155
+ extract_grouped_data(
156
+ get_table_value(lookup, 'termid', country_id, column_name(land_use_term)),
157
+ str(end_year))
158
+ )
159
+ for land_use_term in ALL_LAND_USE_TERMS + [LAND_AREA]
160
+ }
161
+ changes_dict[TOTAL_AGRICULTURAL_CHANGE] = (float(changes_dict.get(TOTAL_CROPLAND, 0))
162
+ + float(changes_dict.get(PERMANENT_PASTURE, 0)))
163
+
164
+ return changes_dict
165
+
166
+
167
+ def _get_ratio_start_and_end_values(
168
+ expansion: float,
169
+ fao_name: str,
170
+ country_id: str,
171
+ end_year: int
172
+ ) -> float:
173
+ # expansion over twenty years / current area
174
+ lookup = download_lookup('region-faostatArea.csv')
175
+ table_value = get_table_value(lookup, 'termid', country_id, column_name(fao_name))
176
+ end_value = safe_parse_float(value=extract_grouped_data_closest_date(table_value, end_year), default=None)
177
+ return max(0.0, _safe_divide(numerator=expansion, denominator=end_value))
178
+
179
+
180
+ def _estimate_maximum_forest_change(
181
+ forest_change: float, total_cropland_change: float, pasture_change: float, total_agricultural_change: float
182
+ ):
183
+ """
184
+ (L): Estimate maximum forest loss
185
+ Gives a negative number representing forest loss. Does not currently handle forest gain.
186
+ """
187
+ positive_change = pasture_change > 0 and total_cropland_change > 0
188
+ return _negative_agricultural_land_change(
189
+ forest_change=forest_change,
190
+ pasture_change=pasture_change,
191
+ total_cropland_change=total_cropland_change
192
+ ) if not positive_change else (
193
+ total_agricultural_change
194
+ if -min(forest_change, 0) > total_agricultural_change else
195
+ min(forest_change, 0)
196
+ )
197
+
198
+
199
+ def _negative_agricultural_land_change(forest_change, pasture_change, total_cropland_change):
200
+ return pasture_change if 0 < pasture_change < -min(forest_change, 0) \
201
+ else min(forest_change, 0) if pasture_change > 0 \
202
+ else -total_cropland_change if 0 < total_cropland_change < -min(forest_change, 0) \
203
+ else min(forest_change, 0) if 0 < total_cropland_change \
204
+ else 0
205
+
206
+
207
+ def _allocate_forest_loss(forest_loss: float, changes: dict):
208
+ """Allocate forest loss between agricultural categories for the specific country"""
209
+ return {
210
+ TOTAL_CROPLAND: forest_loss * _safe_divide(
211
+ numerator=max(changes[TOTAL_CROPLAND], 0),
212
+ denominator=max(changes[TOTAL_CROPLAND], 0) + max(changes[PERMANENT_PASTURE], 0)
213
+ ),
214
+ PERMANENT_PASTURE: forest_loss * _safe_divide(
215
+ numerator=max(changes[PERMANENT_PASTURE], 0),
216
+ denominator=max(changes[TOTAL_CROPLAND], 0) + max(changes[PERMANENT_PASTURE], 0)
217
+ )
218
+ }
219
+
220
+
221
+ def _additional_allocation(changes, max_forest_loss_to_cropland, max_forest_loss_to_permanent_pasture):
222
+ """Determine how much area still needs to be assigned"""
223
+ return {
224
+ TOTAL_CROPLAND: max(changes[TOTAL_CROPLAND], 0) + max_forest_loss_to_cropland,
225
+ PERMANENT_PASTURE: max(changes[PERMANENT_PASTURE], 0) + max_forest_loss_to_permanent_pasture
226
+ }
227
+
228
+
229
+ def _allocate_cropland_loss_to_pasture(changes: dict, land_required_for_permanent_pasture: float):
230
+ """Allocate changes between Permanent pasture and cropland"""
231
+ return (
232
+ max(-land_required_for_permanent_pasture, changes[TOTAL_CROPLAND])
233
+ if changes[TOTAL_CROPLAND] < 0 else 0
234
+ )
235
+
236
+
237
+ def _allocate_pasture_loss_to_cropland(changes: dict, land_required_for_cropland: float):
238
+ """Allocate changes between Permanent pasture and cropland"""
239
+ return (
240
+ max(-land_required_for_cropland, changes[PERMANENT_PASTURE])
241
+ if changes[PERMANENT_PASTURE] < 0 else 0
242
+ )
243
+
244
+
245
+ def _allocate_other_land(
246
+ changes: dict, max_forest_loss_to: dict, pasture_loss_to_cropland: float, cropland_loss_to_pasture: float
247
+ ) -> dict:
248
+ """Allocate changes between Other land and cropland"""
249
+ other_land_loss_to_cropland = (
250
+ -(max(changes[TOTAL_CROPLAND], 0) + max_forest_loss_to[TOTAL_CROPLAND]
251
+ + pasture_loss_to_cropland)
252
+ )
253
+ other_land_loss_to_pasture = (
254
+ -(max(changes[PERMANENT_PASTURE], 0) + max_forest_loss_to[PERMANENT_PASTURE]
255
+ + cropland_loss_to_pasture)
256
+ )
257
+ return {
258
+ TOTAL_CROPLAND: other_land_loss_to_cropland,
259
+ PERMANENT_PASTURE: other_land_loss_to_pasture,
260
+ TOTAL_AGRICULTURAL_CHANGE: other_land_loss_to_cropland + other_land_loss_to_pasture
261
+ }
262
+
263
+
264
+ def _allocate_annual_permanent_cropland_losses(changes: dict) -> tuple:
265
+ """
266
+ (Z, AA): Allocate changes between Annual cropland and Permanent cropland
267
+ Returns: annual_cropland_loss_to_permanent_cropland, permanent_cropland_loss_to_annual_cropland
268
+ """
269
+ return (
270
+ -min(-changes[ANNUAL_CROPLAND], changes[PERMANENT_CROPLAND])
271
+ if (changes[ANNUAL_CROPLAND] < 0 and changes[PERMANENT_CROPLAND] > 0) else 0,
272
+ -min(changes[ANNUAL_CROPLAND], -changes[PERMANENT_CROPLAND])
273
+ if (changes[ANNUAL_CROPLAND] > 0 and changes[PERMANENT_CROPLAND] < 0) else 0
274
+ )
275
+
276
+
277
+ def _estimate_conversions_to_annual_cropland(
278
+ changes: dict,
279
+ pasture_loss_to_crops: float,
280
+ forest_loss_to_cropland: float,
281
+ other_land_loss_to_annual_cropland: float,
282
+ permanent_to_annual_cropland: float
283
+ ) -> dict:
284
+ """(AC-AG): Estimate percentage of land sources when converted to: Annual cropland"""
285
+ # -> percent_annual_cropland_was[]
286
+ def conversion_to_annual_cropland(factor: float):
287
+ return factor * _safe_divide(
288
+ numerator=_safe_divide(
289
+ numerator=max(changes[ANNUAL_CROPLAND], 0),
290
+ denominator=max(changes[ANNUAL_CROPLAND], 0) + max(changes[PERMANENT_CROPLAND], 0)),
291
+ denominator=-changes[ANNUAL_CROPLAND]
292
+ )
293
+
294
+ percentages = {
295
+ FOREST_LAND: conversion_to_annual_cropland(forest_loss_to_cropland),
296
+ OTHER_LAND: conversion_to_annual_cropland(other_land_loss_to_annual_cropland),
297
+ PERMANENT_PASTURE: conversion_to_annual_cropland(pasture_loss_to_crops),
298
+ PERMANENT_CROPLAND: _safe_divide(numerator=permanent_to_annual_cropland, denominator=-changes[ANNUAL_CROPLAND])
299
+ }
300
+ return percentages
301
+
302
+
303
+ def _estimate_conversions_to_permanent_cropland(
304
+ changes: dict,
305
+ annual_loss_to_permanent_cropland: float,
306
+ pasture_loss_to_cropland: float,
307
+ forest_loss_to_cropland: float,
308
+ other_land_loss_to_annual_cropland: float
309
+ ) -> dict:
310
+ """Estimate percentage of land sources when converted to: Annual cropland"""
311
+ def conversion_to_permanent_cropland(factor: float):
312
+ return _safe_divide(
313
+ numerator=_safe_divide(
314
+ numerator=factor * max(changes[PERMANENT_CROPLAND], 0),
315
+ denominator=max(changes[ANNUAL_CROPLAND], 0) + max(changes[PERMANENT_CROPLAND], 0)),
316
+ denominator=-changes[PERMANENT_CROPLAND]
317
+ )
318
+
319
+ percentages = {
320
+ FOREST_LAND: conversion_to_permanent_cropland(forest_loss_to_cropland),
321
+ OTHER_LAND: conversion_to_permanent_cropland(other_land_loss_to_annual_cropland),
322
+ PERMANENT_PASTURE: conversion_to_permanent_cropland(pasture_loss_to_cropland),
323
+ ANNUAL_CROPLAND: conversion_to_permanent_cropland(annual_loss_to_permanent_cropland)
324
+ }
325
+ return percentages
326
+
327
+
328
+ def _estimate_conversions_to_pasture(
329
+ changes: dict,
330
+ forest_loss_to_pasture: float,
331
+ total_cropland_loss_to_pasture: float,
332
+ other_land_loss_to_pasture: float
333
+ ) -> dict:
334
+ """Estimate percentage of land sources when converted to: Permanent pasture"""
335
+ percentages = {
336
+ FOREST_LAND: _safe_divide(
337
+ numerator=forest_loss_to_pasture,
338
+ denominator=-changes[PERMANENT_PASTURE],
339
+ ),
340
+ OTHER_LAND: _safe_divide(
341
+ numerator=other_land_loss_to_pasture,
342
+ denominator=-changes[PERMANENT_PASTURE]
343
+ ),
344
+ # AT
345
+ ANNUAL_CROPLAND: _safe_divide(
346
+ numerator=total_cropland_loss_to_pasture * _safe_divide(
347
+ numerator=min(changes[ANNUAL_CROPLAND], 0),
348
+ denominator=(min(changes[ANNUAL_CROPLAND], 0) + min(changes[PERMANENT_CROPLAND], 0))
349
+ ),
350
+ denominator=-changes[PERMANENT_PASTURE]
351
+ ),
352
+ PERMANENT_CROPLAND: _safe_divide(
353
+ numerator=total_cropland_loss_to_pasture * _safe_divide(
354
+ numerator=min(changes[PERMANENT_CROPLAND], 0),
355
+ denominator=(min(changes[ANNUAL_CROPLAND], 0) + min(changes[PERMANENT_CROPLAND], 0))
356
+ ),
357
+ denominator=-changes[PERMANENT_PASTURE]
358
+ )
359
+ }
360
+ return percentages
361
+
362
+
363
+ def _get_shares_of_expansion(
364
+ land_use_type: str,
365
+ percent_annual_cropland_was: dict,
366
+ percent_permanent_cropland_was: dict,
367
+ percent_pasture_was: dict
368
+ ) -> dict:
369
+ expansion_for_type = {
370
+ ANNUAL_CROPLAND: percent_annual_cropland_was,
371
+ PERMANENT_CROPLAND: percent_permanent_cropland_was,
372
+ PERMANENT_PASTURE: percent_pasture_was
373
+ }
374
+ return {
375
+ k: expansion_for_type[land_use_type].get(k, 0)
376
+ for k in LAND_USE_TERMS_FOR_TRANSFORMATION.keys()
377
+ }
378
+
379
+
380
+ def _get_faostat_name(term: dict) -> str:
381
+ """For landCover terms, find the cropGroupingFaostatArea name for the landCover id."""
382
+ return get_lookup_value(term, "cropGroupingFaostatArea")
383
+
384
+
385
+ def _get_complete_faostat_to_crop_mapping() -> dict:
386
+ """Returns mapping in the format: {faostat_name: IPPC_LAND_USE_CATEGORY, ...}"""
387
+ lookup = download_lookup("crop.csv")
388
+ mappings = defaultdict(list)
389
+ for crop_term_id in [row[0] for row in lookup]:
390
+ key = column_name(
391
+ get_table_value(lookup, 'termid', crop_term_id, column_name("cropGroupingFaostatArea"))
392
+ )
393
+ if key:
394
+ mappings[key].append(_crop_ipcc_land_use_category(crop_term_id=crop_term_id, lookup_term_type="crop"))
395
+ return {
396
+ fao_name: max(set(crop_terms), key=crop_terms.count)
397
+ for fao_name, crop_terms in mappings.items()
398
+ }
399
+
400
+
401
+ def _get_harvested_area(country_id: str, year: int, faostat_name: str) -> float:
402
+ """
403
+ Returns a dictionary of harvested areas for the country & year, indexed by landCover term (crop)
404
+ """
405
+ lookup = download_lookup("region-crop-cropGroupingFaostatProduction-areaHarvested.csv")
406
+ return safe_parse_float(
407
+ value=extract_grouped_data_closest_date(
408
+ data=get_table_value(lookup, "termid", country_id, column_name(faostat_name)),
409
+ year=year
410
+ ),
411
+ default=None
412
+ )
413
+
414
+
415
+ def _run_make_management_nodes(existing_nodes: list, percentage_transformed_from: dict, start_year: int) -> list:
416
+ """Creates a list of new management nodes, excluding any dates matching existing ones."""
417
+ existing_nodes_set = {
418
+ (node.get("term", {}).get("@id", ""), node.get("startDate"), node.get("endDate"))
419
+ for node in existing_nodes
420
+ }
421
+ values = [
422
+ {
423
+ "land_management_key": (
424
+ LAND_USE_TERMS_FOR_TRANSFORMATION[land_type], f"{start_year}-01-01", f"{start_year}-12-31"
425
+ ),
426
+ "land_type": land_type,
427
+ "percentage": 0.0 if ratio == -0.0 else to_precision(
428
+ number=ratio * 100,
429
+ digits=OUTPUT_SIGNIFICANT_DIGITS
430
+ )
431
+ }
432
+ for land_type, ratio in percentage_transformed_from.items()
433
+ ]
434
+ values = [v for v in values if v.get("land_management_key") not in existing_nodes_set]
435
+ nodes = [
436
+ {
437
+ "term": {
438
+ "@type": "Term",
439
+ "@id": LAND_USE_TERMS_FOR_TRANSFORMATION[value.get("land_type")][0],
440
+ "name": LAND_USE_TERMS_FOR_TRANSFORMATION[value.get("land_type")][1],
441
+ "termType": TermTermType.LANDCOVER.value
442
+ },
443
+ "value": value.get("percentage"),
444
+ "startDate": value.get("land_management_key")[1],
445
+ "endDate": value.get("land_management_key")[2],
446
+ "@type": "Management"
447
+ } for value in values
448
+ ]
449
+
450
+ return nodes
451
+
452
+
453
+ def get_ratio_of_expanded_area(country_id: str, fao_name: str, end_year: int) -> float:
454
+ lookup = download_lookup("region-crop-cropGroupingFaostatProduction-areaHarvestedUpTo20YearExpansion.csv")
455
+ table_value = get_table_value(lookup, 'termid', country_id, column_name(fao_name))
456
+ expansion = safe_parse_float(value=extract_grouped_data(table_value, str(end_year)), default=None)
457
+ end_value = _get_harvested_area(
458
+ country_id=country_id,
459
+ year=end_year,
460
+ faostat_name=fao_name
461
+ )
462
+ return 0.0 if any([expansion is None, end_value is None]) else max(
463
+ 0.0, _safe_divide(numerator=expansion, denominator=(end_value - expansion))
464
+ )
465
+
466
+
467
+ def _get_sum_for_land_category(
468
+ values: dict,
469
+ year: int,
470
+ ipcc_land_use_category,
471
+ fao_stat_to_ipcc_type: dict,
472
+ include_negatives: bool = True
473
+ ) -> float:
474
+ return sum(
475
+ [
476
+ safe_parse_float(value=extract_grouped_data(table_value, str(year)), default=None)
477
+ for fao_name, table_value in values.items()
478
+ if not _is_missing_or_none(extract_grouped_data(table_value, str(year))) and
479
+ fao_stat_to_ipcc_type[fao_name] == ipcc_land_use_category and
480
+ (include_negatives or
481
+ safe_parse_float(value=extract_grouped_data(table_value, str(year)), default=None) > 0.0)
482
+ ]
483
+ )
484
+
485
+
486
+ def _get_sums_of_crop_expansion(country_id: str, year: int, include_negatives: bool = True) -> tuple[float, float]:
487
+ """
488
+ Sum net expansion for all annual and permanent crops, returned as two values.
489
+ Returns a tuple of (expansion of annual crops, expansion of permanent crops)
490
+ """
491
+ lookup = download_lookup("region-crop-cropGroupingFaostatProduction-areaHarvestedUpTo20YearExpansion.csv")
492
+ values = {name: get_table_value(lookup, 'termid', country_id, column_name(name))
493
+ for name in list(lookup.dtype.names) if name != "termid"}
494
+
495
+ fao_stat_to_ipcc_type = _get_complete_faostat_to_crop_mapping()
496
+
497
+ annual_sum_of_expansion = _get_sum_for_land_category(
498
+ values=values,
499
+ year=year,
500
+ ipcc_land_use_category=IPCC_LAND_USE_CATEGORY_ANNUAL,
501
+ fao_stat_to_ipcc_type=fao_stat_to_ipcc_type,
502
+ include_negatives=include_negatives
503
+ )
504
+ permanent_sum_of_expansion = _get_sum_for_land_category(
505
+ values=values,
506
+ year=year,
507
+ ipcc_land_use_category=IPCC_LAND_USE_CATEGORY_PERENNIAL,
508
+ fao_stat_to_ipcc_type=fao_stat_to_ipcc_type,
509
+ include_negatives=include_negatives
510
+ )
511
+
512
+ return annual_sum_of_expansion, permanent_sum_of_expansion
513
+
514
+
515
+ def _get_net_expansion_cultivated_vs_harvested(annual_crops_net_expansion, changes, land_use_type,
516
+ permanent_crops_net_expansion):
517
+ if land_use_type == ANNUAL_CROPLAND:
518
+ net_expansion_cultivated_vs_harvested = _safe_divide(numerator=max(0, changes[ANNUAL_CROPLAND]),
519
+ denominator=(annual_crops_net_expansion / 1000))
520
+ elif land_use_type == PERMANENT_CROPLAND:
521
+ net_expansion_cultivated_vs_harvested = _safe_divide(numerator=max(0, changes[PERMANENT_CROPLAND]),
522
+ denominator=(permanent_crops_net_expansion / 1000))
523
+ else:
524
+ net_expansion_cultivated_vs_harvested = 1
525
+ return net_expansion_cultivated_vs_harvested
526
+
527
+
528
+ def _should_run_historical_land_use_change(site: dict, land_use_type: str) -> tuple[bool, dict]:
529
+ management_nodes = filter_list_term_type(site.get("management", []), TermTermType.LANDCOVER)
530
+ # Assume a single management node for single-cropping.
531
+ return _should_run_historical_land_use_change_single_crop(
532
+ site=site,
533
+ term=management_nodes[0].get("term", {}),
534
+ country_id=site.get("country", {}).get("@id"),
535
+ end_year=int(management_nodes[0].get("endDate")[:4]),
536
+ land_use_type=land_use_type
537
+ )
538
+
539
+
540
+ def _should_run_historical_land_use_change_single_crop(
541
+ site: dict,
542
+ term: dict,
543
+ country_id: str,
544
+ end_year: int,
545
+ land_use_type: str
546
+ ) -> tuple[bool, dict]:
547
+ """Calculate land use change percentages for a single management node/crop."""
548
+ # (C-H).
549
+ changes = get_changes(country_id=country_id, end_year=end_year)
550
+
551
+ # (L). Estimate maximum forest loss
552
+ forest_loss = _estimate_maximum_forest_change(
553
+ forest_change=changes[FOREST_LAND],
554
+ total_cropland_change=changes[TOTAL_CROPLAND],
555
+ pasture_change=changes[PERMANENT_PASTURE],
556
+ total_agricultural_change=changes[TOTAL_AGRICULTURAL_CHANGE]
557
+ )
558
+
559
+ # (M, N). Allocate forest loss between agricultural categories for the specific country
560
+ forest_loss_to = _allocate_forest_loss(forest_loss=forest_loss, changes=changes)
561
+
562
+ # (P, Q): Determine how much area still needs to be assigned
563
+ land_required_for = _additional_allocation(
564
+ changes=changes,
565
+ max_forest_loss_to_cropland=forest_loss_to[TOTAL_CROPLAND],
566
+ max_forest_loss_to_permanent_pasture=forest_loss_to[PERMANENT_PASTURE]
567
+ )
568
+
569
+ # (R): Allocate changes between Permanent pasture and cropland
570
+ cropland_loss_to_pasture = _allocate_cropland_loss_to_pasture(
571
+ changes=changes,
572
+ land_required_for_permanent_pasture=land_required_for[PERMANENT_PASTURE]
573
+ )
574
+ # (S)
575
+ pasture_loss_to_cropland = _allocate_pasture_loss_to_cropland(
576
+ changes=changes,
577
+ land_required_for_cropland=land_required_for[TOTAL_CROPLAND]
578
+ )
579
+
580
+ # (V): Allocate changes between Other land and cropland
581
+ other_land_loss_to = _allocate_other_land(
582
+ changes=changes,
583
+ max_forest_loss_to=forest_loss_to,
584
+ pasture_loss_to_cropland=pasture_loss_to_cropland,
585
+ cropland_loss_to_pasture=cropland_loss_to_pasture
586
+ )
587
+
588
+ # (Z, AA): Allocate changes between Annual cropland and Permanent cropland
589
+ annual_cropland_loss_to_permanent_cropland, permanent_cropland_loss_to_annual_cropland = (
590
+ _allocate_annual_permanent_cropland_losses(changes)
591
+ )
592
+
593
+ # (AC-AG): Estimate percentage of land sources when converted to: Annual cropland
594
+ # Note: All percentages are expressed as decimal fractions. 50% = 0.5
595
+ percent_annual_cropland_was = _estimate_conversions_to_annual_cropland(
596
+ changes=changes,
597
+ pasture_loss_to_crops=pasture_loss_to_cropland,
598
+ forest_loss_to_cropland=forest_loss_to[TOTAL_CROPLAND],
599
+ other_land_loss_to_annual_cropland=other_land_loss_to[TOTAL_CROPLAND],
600
+ permanent_to_annual_cropland=permanent_cropland_loss_to_annual_cropland,
601
+ )
602
+
603
+ # (AJ-AM): Estimate percentage of land sources when converted to: Permanent cropland
604
+ percent_permanent_cropland_was = _estimate_conversions_to_permanent_cropland(
605
+ changes=changes,
606
+ annual_loss_to_permanent_cropland=annual_cropland_loss_to_permanent_cropland,
607
+ pasture_loss_to_cropland=pasture_loss_to_cropland,
608
+ forest_loss_to_cropland=forest_loss_to[TOTAL_CROPLAND],
609
+ other_land_loss_to_annual_cropland=other_land_loss_to[TOTAL_CROPLAND]
610
+ )
611
+
612
+ # Estimate percentage of land sources when converted to: Permanent pasture
613
+ percent_pasture_was = _estimate_conversions_to_pasture(
614
+ changes=changes,
615
+ forest_loss_to_pasture=forest_loss_to[PERMANENT_PASTURE],
616
+ total_cropland_loss_to_pasture=cropland_loss_to_pasture,
617
+ other_land_loss_to_pasture=other_land_loss_to[PERMANENT_PASTURE]
618
+ )
619
+
620
+ # BA to BD
621
+ shares_of_expansion = _get_shares_of_expansion(
622
+ land_use_type=land_use_type,
623
+ percent_annual_cropland_was=percent_annual_cropland_was,
624
+ percent_permanent_cropland_was=percent_permanent_cropland_was,
625
+ percent_pasture_was=percent_pasture_was
626
+ )
627
+
628
+ # Cell E8
629
+ expansion_factor = _get_ratio_start_and_end_values(
630
+ expansion=changes[PERMANENT_PASTURE],
631
+ fao_name=PERMANENT_PASTURE,
632
+ country_id=country_id,
633
+ end_year=end_year
634
+ ) if land_use_type == PERMANENT_PASTURE else get_ratio_of_expanded_area(
635
+ country_id=country_id,
636
+ fao_name=_get_faostat_name(term),
637
+ end_year=end_year
638
+ )
639
+
640
+ # E9
641
+ annual_crops_net_expansion, permanent_crops_net_expansion = _get_sums_of_crop_expansion(
642
+ country_id=country_id,
643
+ year=end_year,
644
+ include_negatives=True
645
+ )
646
+ annual_crops_gross_expansion, permanent_crops_gross_expansion = _get_sums_of_crop_expansion(
647
+ country_id=country_id,
648
+ year=end_year,
649
+ include_negatives=False
650
+ )
651
+ e9_net_expansion = _safe_divide(
652
+ numerator=permanent_crops_net_expansion,
653
+ denominator=permanent_crops_gross_expansion
654
+ ) if land_use_type == PERMANENT_CROPLAND else (
655
+ _safe_divide(
656
+ numerator=annual_crops_net_expansion,
657
+ denominator=annual_crops_gross_expansion
658
+ ) if land_use_type == ANNUAL_CROPLAND else 1
659
+ )
660
+
661
+ # E10: Compare changes to annual/perennial cropland from net expansion.
662
+ net_expansion_cultivated_vs_harvested = _get_net_expansion_cultivated_vs_harvested(
663
+ annual_crops_net_expansion=annual_crops_net_expansion,
664
+ changes=changes,
665
+ land_use_type=land_use_type,
666
+ permanent_crops_net_expansion=permanent_crops_net_expansion
667
+ )
668
+
669
+ site_area = {
670
+ land_type: (
671
+ shares_of_expansion[land_type] * expansion_factor * e9_net_expansion * net_expansion_cultivated_vs_harvested
672
+ )
673
+ for land_type in LAND_USE_TERMS_FOR_TRANSFORMATION.keys()
674
+ if land_type != land_use_type
675
+ }
676
+ site_area[land_use_type] = 1 - sum(site_area.values())
677
+
678
+ sum_of_site_areas_is_100 = site_area_sum_to_100(site_area)
679
+ logRequirements(
680
+ log_node=site,
681
+ model=MODEL,
682
+ term=term.get("@id"),
683
+ model_key=MODEL_KEY,
684
+ land_use_type=land_use_type,
685
+ country_id=country_id,
686
+ site_area=log_as_table(site_area),
687
+ sum_of_site_areas_is_100=sum_of_site_areas_is_100
688
+ )
689
+
690
+ should_run = all(
691
+ [
692
+ site.get("siteType"),
693
+ country_id,
694
+ non_empty_value(term),
695
+ site.get("siteType") in SITE_TYPES,
696
+ sum_of_site_areas_is_100
697
+ ]
698
+ )
699
+ logShouldRun(site, MODEL_ID, term=term.get("@id"), should_run=should_run, key=MODEL_KEY)
700
+
701
+ return should_run, site_area
702
+
703
+
704
+ def _should_run(site: dict) -> tuple[bool, dict]:
705
+ management_nodes = filter_list_term_type(site.get("management", []), TermTermType.LANDCOVER)
706
+ land_use_type = _lookup_land_use_type(nodes=management_nodes)
707
+ should_run_result, site_area = (
708
+ (False, {}) if land_use_type not in {ANNUAL_CROPLAND, PERMANENT_CROPLAND, PERMANENT_PASTURE}
709
+ else _should_run_historical_land_use_change(
710
+ site=site,
711
+ land_use_type=land_use_type
712
+ )
713
+ )
714
+
715
+ return should_run_result, site_area
716
+
717
+
718
+ def run(site: dict) -> list:
719
+ should_run, site_area = _should_run(site)
720
+ management_nodes = filter_list_term_type(site.get("management", []), TermTermType.LANDCOVER)
721
+ return _run_make_management_nodes(
722
+ existing_nodes=management_nodes,
723
+ percentage_transformed_from=site_area,
724
+ start_year=int(management_nodes[0].get("endDate")[:4]) - DEFAULT_WINDOW_IN_YEARS
725
+ ) if should_run else []