hestia-earth-models 0.74.4__py3-none-any.whl → 0.74.5__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 (62) hide show
  1. hestia_earth/models/cml2001Baseline/abioticResourceDepletionMineralsAndMetals.py +0 -1
  2. hestia_earth/models/config/Cycle.json +15 -0
  3. hestia_earth/models/config/ImpactAssessment.json +9 -1
  4. hestia_earth/models/cycle/animal/input/hestiaAggregatedData.py +3 -3
  5. hestia_earth/models/cycle/completeness/seed.py +1 -1
  6. hestia_earth/models/cycle/input/hestiaAggregatedData.py +25 -16
  7. hestia_earth/models/data/hestiaAggregatedData/__init__.py +73 -0
  8. hestia_earth/models/environmentalFootprintV3_1/scarcityWeightedWaterUse.py +1 -1
  9. hestia_earth/models/environmentalFootprintV3_1/soilQualityIndexLandOccupation.py +5 -6
  10. hestia_earth/models/environmentalFootprintV3_1/soilQualityIndexLandTransformation.py +10 -13
  11. hestia_earth/models/fantkeEtAl2016/damageToHumanHealthParticulateMatterFormation.py +1 -1
  12. hestia_earth/models/hestia/landCover.py +24 -0
  13. hestia_earth/models/hestia/landOccupationDuringCycle.py +80 -51
  14. hestia_earth/models/hestia/landTransformation100YearAverageDuringCycle.py +7 -1
  15. hestia_earth/models/hestia/landTransformation20YearAverageDuringCycle.py +7 -1
  16. hestia_earth/models/hestia/resourceUse_utils.py +58 -119
  17. hestia_earth/models/hestia/waterSalinity.py +57 -12
  18. hestia_earth/models/impact_assessment/post_checks/__init__.py +3 -2
  19. hestia_earth/models/impact_assessment/post_checks/remove_cache_fields.py +9 -0
  20. hestia_earth/models/impact_assessment/pre_checks/cache_emissionsResourceUse.py +21 -0
  21. hestia_earth/models/impact_assessment/pre_checks/cycle.py +5 -0
  22. hestia_earth/models/ipcc2019/co2ToAirAboveGroundBiomassStockChange.py +6 -64
  23. hestia_earth/models/ipcc2019/co2ToAirBelowGroundBiomassStockChange.py +9 -87
  24. hestia_earth/models/ipcc2019/co2ToAirBiocharStockChange.py +140 -0
  25. hestia_earth/models/ipcc2019/co2ToAirCarbonStockChange_utils.py +329 -217
  26. hestia_earth/models/ipcc2019/co2ToAirSoilOrganicCarbonStockChange.py +10 -87
  27. hestia_earth/models/mocking/__init__.py +2 -2
  28. hestia_earth/models/mocking/mock_search.py +20 -10
  29. hestia_earth/models/mocking/search-results.json +1 -7679
  30. hestia_earth/models/pooreNemecek2018/landOccupationDuringCycle.py +8 -7
  31. hestia_earth/models/poschEtAl2008/terrestrialAcidificationPotentialAccumulatedExceedance.py +1 -1
  32. hestia_earth/models/poschEtAl2008/terrestrialEutrophicationPotentialAccumulatedExceedance.py +1 -1
  33. hestia_earth/models/preload_requests.py +18 -4
  34. hestia_earth/models/schmidt2007/utils.py +3 -3
  35. hestia_earth/models/utils/__init__.py +4 -1
  36. hestia_earth/models/utils/aggregated.py +21 -68
  37. hestia_earth/models/utils/cycle.py +3 -3
  38. hestia_earth/models/utils/impact_assessment.py +45 -41
  39. hestia_earth/models/utils/lookup.py +92 -67
  40. hestia_earth/models/version.py +1 -1
  41. hestia_earth/orchestrator/models/__init__.py +47 -10
  42. hestia_earth/orchestrator/models/transformations.py +3 -1
  43. hestia_earth/orchestrator/strategies/merge/__init__.py +1 -2
  44. hestia_earth/orchestrator/strategies/merge/merge_list.py +31 -8
  45. hestia_earth/orchestrator/utils.py +29 -0
  46. {hestia_earth_models-0.74.4.dist-info → hestia_earth_models-0.74.5.dist-info}/METADATA +2 -3
  47. {hestia_earth_models-0.74.4.dist-info → hestia_earth_models-0.74.5.dist-info}/RECORD +62 -55
  48. tests/models/cycle/animal/input/test_hestiaAggregatedData.py +3 -3
  49. tests/models/cycle/input/test_hestiaAggregatedData.py +9 -18
  50. tests/models/data/__init__.py +0 -0
  51. tests/models/data/test_hestiaAggregatedData.py +32 -0
  52. tests/models/hestia/test_landCover.py +32 -1
  53. tests/models/hestia/test_waterSalinity.py +16 -4
  54. tests/models/ipcc2019/test_co2ToAirAboveGroundBiomassStockChange.py +1 -6
  55. tests/models/ipcc2019/test_co2ToAirBelowGroundBiomassStockChange.py +1 -6
  56. tests/models/ipcc2019/test_co2ToAirBiocharStockChange.py +90 -0
  57. tests/models/ipcc2019/test_co2ToAirSoilOrganicCarbonStockChange.py +1 -6
  58. tests/models/pooreNemecek2018/test_landOccupationDuringCycle.py +1 -0
  59. tests/orchestrator/strategies/merge/test_merge_list.py +5 -0
  60. {hestia_earth_models-0.74.4.dist-info → hestia_earth_models-0.74.5.dist-info}/LICENSE +0 -0
  61. {hestia_earth_models-0.74.4.dist-info → hestia_earth_models-0.74.5.dist-info}/WHEEL +0 -0
  62. {hestia_earth_models-0.74.4.dist-info → hestia_earth_models-0.74.5.dist-info}/top_level.txt +0 -0
@@ -80,7 +80,6 @@ def _should_run(impact_assessment: dict) -> tuple[bool, list]:
80
80
  "input-term-type": input.get('termType'),
81
81
  "indicator-term-id": resource_indicator['term']['@id'],
82
82
  "indicator-is-valid": _valid_resource_indicator(resource_indicator),
83
- "input": input,
84
83
  "indicator-input-is-valid": _valid_input(input),
85
84
  "value": _node_value(resource_indicator),
86
85
  "coefficient": get_table_value(
@@ -1264,6 +1264,21 @@
1264
1264
  },
1265
1265
  "stage": 2
1266
1266
  },
1267
+ {
1268
+ "key": "emissions",
1269
+ "model": "ipcc2019",
1270
+ "value": "co2ToAirBiocharStockChange",
1271
+ "runStrategy": "add_blank_node_if_missing",
1272
+ "runArgs": {
1273
+ "runNonMeasured": true,
1274
+ "runNonAddedTerm": true
1275
+ },
1276
+ "mergeStrategy": "list",
1277
+ "mergeArgs": {
1278
+ "replaceThreshold": ["value", 0.01]
1279
+ },
1280
+ "stage": 2
1281
+ },
1267
1282
  {
1268
1283
  "key": "emissions",
1269
1284
  "model": "ipcc2019",
@@ -82,7 +82,7 @@
82
82
  "key": "emissionsResourceUse",
83
83
  "model": "pooreNemecek2018",
84
84
  "value": "landOccupationDuringCycle",
85
- "runStrategy": "always",
85
+ "runStrategy": "add_blank_node_if_missing",
86
86
  "mergeStrategy": "list",
87
87
  "mergeArgs": {
88
88
  "replaceThreshold": ["value", 0.01]
@@ -225,6 +225,14 @@
225
225
  },
226
226
  "stage": 1
227
227
  },
228
+ {
229
+ "key": "cache_emissionsResourceUse",
230
+ "model": "impact_assessment",
231
+ "value": "pre_checks.cache_emissionsResourceUse",
232
+ "runStrategy": "always",
233
+ "mergeStrategy": "default",
234
+ "stage": 1
235
+ },
228
236
  {
229
237
  "key": "impacts",
230
238
  "model": "ipcc2021",
@@ -34,8 +34,8 @@ MODEL_ID = 'hestiaAggregatedData'
34
34
  MODEL_KEY = 'impactAssessment'
35
35
 
36
36
 
37
- def _run_animal_input(cycle: dict, input: dict):
38
- inputs = link_inputs_to_impact(MODEL_ID, cycle, [input])
37
+ def _run_animal_input(cycle: dict, animal: dict, input: dict):
38
+ inputs = link_inputs_to_impact(MODEL_ID, cycle, [input], animalId=animal.get('animalId'))
39
39
  return inputs[0] if inputs else input
40
40
 
41
41
 
@@ -43,7 +43,7 @@ def _run_animal(cycle: dict, animal: dict):
43
43
  return animal | {
44
44
  'inputs': [
45
45
  (
46
- _run_animal_input(cycle, input) if should_link_input_to_impact(cycle)(input) else input
46
+ _run_animal_input(cycle, animal, input) if should_link_input_to_impact(cycle)(input) else input
47
47
  ) for input in animal.get('inputs', [])
48
48
  ]
49
49
  }
@@ -36,7 +36,7 @@ def run(cycle: dict):
36
36
  site_type = cycle.get('site', {}).get('siteType')
37
37
  site_type_allowed = site_type in ALLOWED_SITE_TYPES
38
38
 
39
- has_seed = find_term_match(cycle.get('inputs', []), 'seed', None)
39
+ has_seed = find_term_match(cycle.get('inputs', []), 'seed', None) is not None
40
40
 
41
41
  product = find_primary_product(cycle) or {}
42
42
  term_id = product.get('term', {}).get('@id')
@@ -1,12 +1,13 @@
1
- from hestia_earth.schema import TermTermType
1
+ from hestia_earth.schema import TermTermType, NodeType
2
2
  from hestia_earth.utils.model import find_primary_product, linked_node, filter_list_term_type
3
3
  from hestia_earth.utils.tools import non_empty_list
4
4
 
5
5
  from hestia_earth.models.log import debugValues, logRequirements, logShouldRun
6
+ from hestia_earth.models.data.hestiaAggregatedData import DEFAULT_COUNTRY_ID, find_closest_impact_id
6
7
  from hestia_earth.models.utils.crop import valid_site_type
7
- from hestia_earth.models.utils.term import get_lookup_value, get_generic_crop, download_term
8
+ from hestia_earth.models.utils.term import get_lookup_value, get_generic_crop
8
9
  from hestia_earth.models.utils.aggregated import (
9
- should_link_input_to_impact, link_inputs_to_impact, find_closest_impact, aggregated_end_date
10
+ should_link_input_to_impact, link_inputs_to_impact, aggregated_end_date
10
11
  )
11
12
 
12
13
  REQUIREMENTS = {
@@ -56,29 +57,37 @@ MODEL_KEY = 'impactAssessment'
56
57
 
57
58
 
58
59
  def _run_seed(cycle: dict, primary_product: dict, seed_input: dict, product_term_id: str):
59
- product = download_term(product_term_id, TermTermType.SEED)
60
60
  country = seed_input.get('country')
61
+ country_id = (country or {}).get('@id')
62
+
63
+ primary_product_id = primary_product.get('term', {}).get('@id')
64
+ default_product_id = get_generic_crop().get('@id')
65
+
61
66
  # to avoid double counting seed => aggregated impact => seed, we need to get the impact of the previous decade
62
67
  # if the data does not exist, use the aggregated impact of generic crop instead
63
- date = aggregated_end_date(cycle.get('endDate'))
64
- match_end_date = [{'match': {'endDate': date - 10}}]
65
- default_product = get_generic_crop()
66
-
67
- impact = (
68
- find_closest_impact(cycle, date, product, country, must_queries=match_end_date) or
69
- find_closest_impact(cycle, date, primary_product.get('term', {}), country, must_queries=match_end_date) or
70
- find_closest_impact(cycle, date, default_product, country)
68
+ date = aggregated_end_date(cycle.get('endDate')) - 10
69
+
70
+ impact_id = (
71
+ find_closest_impact_id(product_id=product_term_id, country_id=country_id, year=date) or
72
+ find_closest_impact_id(product_id=product_term_id, country_id=DEFAULT_COUNTRY_ID, year=date) or
73
+ find_closest_impact_id(product_id=primary_product_id, country_id=country_id, year=date) or
74
+ find_closest_impact_id(product_id=primary_product_id, country_id=DEFAULT_COUNTRY_ID, year=date) or
75
+ find_closest_impact_id(product_id=default_product_id, country_id=country_id, year=date) or
76
+ find_closest_impact_id(product_id=default_product_id, country_id=DEFAULT_COUNTRY_ID, year=date)
71
77
  )
72
78
 
73
- search_by_product_term_id = (product or primary_product or default_product).get('@id')
74
- search_by_country_id = (country or {}).get('@id') or 'region-world'
79
+ search_by_product_term_id = product_term_id or primary_product_id or default_product_id
80
+ search_by_country_id = country_id or DEFAULT_COUNTRY_ID
75
81
  debugValues(cycle, model=MODEL_ID, term=seed_input.get('term', {}).get('@id'), key=MODEL_KEY,
76
82
  search_by_product_term_id=search_by_product_term_id,
77
83
  search_by_country_id=search_by_country_id,
78
84
  search_by_end_date=str(date),
79
- impact_assessment_id_found=(impact or {}).get('@id'))
85
+ impact_assessment_id_found=impact_id)
80
86
 
81
- return seed_input | {MODEL_KEY: linked_node(impact), 'impactAssessmentIsProxy': True} if impact else None
87
+ return seed_input | {
88
+ MODEL_KEY: linked_node({'@type': NodeType.IMPACTASSESSMENT.value, '@id': impact_id}),
89
+ 'impactAssessmentIsProxy': True
90
+ } if impact_id else None
82
91
 
83
92
 
84
93
  def _should_run_seed(cycle: dict):
@@ -0,0 +1,73 @@
1
+ import os
2
+ import json
3
+ from datetime import datetime
4
+ from hestia_earth.utils.storage._s3_client import _load_from_bucket
5
+ from hestia_earth.utils.api import _safe_get_request
6
+
7
+ _CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
8
+ _FILENAME = 'hestiaAggregatedData.json'
9
+ FILEPATH = os.path.join(_CURRENT_DIR, _FILENAME)
10
+ _CACHED_DATA = {}
11
+ DEFAULT_COUNTRY_ID = 'region-world'
12
+
13
+
14
+ def _today(): return datetime.now().strftime('%Y-%m-%d')
15
+
16
+
17
+ def _download_data():
18
+ try:
19
+ return json.loads(_load_from_bucket('hestia-data', os.path.join('data', _FILENAME)))
20
+ except Exception:
21
+ return _safe_get_request(f"https://hestia.earth/data/{_FILENAME}")
22
+
23
+
24
+ def _load_data():
25
+ data = None
26
+
27
+ if os.path.exists(FILEPATH):
28
+ with open(FILEPATH, 'r') as f:
29
+ data = json.load(f)
30
+
31
+ is_data_valid = data and data['date'] == _today()
32
+
33
+ if not is_data_valid:
34
+ data = _download_data()
35
+ with open(FILEPATH, 'w') as f:
36
+ f.write(json.dumps(data))
37
+
38
+ return data
39
+
40
+
41
+ def _get_data():
42
+ global _CACHED_DATA # noqa: F824
43
+ if not _CACHED_DATA or _CACHED_DATA['date'] != _today():
44
+ _CACHED_DATA = _load_data()
45
+ return _CACHED_DATA
46
+
47
+
48
+ def _get_closest_id(data: dict, year: int):
49
+ available_years = [int(y) for y in data.keys() if int(y) <= year]
50
+ return data[str(sorted(available_years, reverse=True)[0])] if available_years else None
51
+
52
+
53
+ def find_closest_impact_id(product_id: str, country_id: str, year: int):
54
+ """
55
+ Find the `@id` of the closest ImpactAssessment to the target year.
56
+
57
+ Parameters
58
+ ----------
59
+ product_id : str
60
+ The `@id` of the product (Term).
61
+ country_id : str
62
+ The `@id` of the country (Term).
63
+ year : int
64
+ The target year.
65
+
66
+ Returns
67
+ -------
68
+ str
69
+ The `@id` as a string if found.
70
+ """
71
+ data = _get_data()
72
+ values = data.get(product_id, {}).get(country_id, {})
73
+ return _get_closest_id(data=values, year=year)
@@ -33,7 +33,7 @@ def _indicator(value: float):
33
33
 
34
34
  def run(impact_assessment: dict):
35
35
  value = impact_country_value(MODEL, TERM_ID, impact_assessment, f"{list(LOOKUPS.keys())[0]}.csv",
36
- country_fallback=True)
36
+ default_world_value=True)
37
37
  logRequirements(impact_assessment, model=MODEL, term=TERM_ID,
38
38
  value=value)
39
39
  logShouldRun(impact_assessment, MODEL, TERM_ID, value is not None)
@@ -7,7 +7,7 @@ from hestia_earth.utils.tools import list_sum
7
7
  from hestia_earth.models.log import logRequirements, logShouldRun, log_as_table
8
8
  from hestia_earth.models.utils.indicator import _new_indicator
9
9
  from hestia_earth.models.utils.landCover import get_pef_grouping
10
- from hestia_earth.models.utils.lookup import fallback_country, _node_value, get_region_lookup_value
10
+ from hestia_earth.models.utils.lookup import _node_value, get_region_lookup_value
11
11
  from . import MODEL
12
12
  from ..utils.impact_assessment import get_country_id
13
13
 
@@ -68,7 +68,6 @@ def _should_run(impact_assessment: dict) -> Tuple[bool, list]:
68
68
  'area-by-year-is-valid': _node_value(indicator) is not None and _node_value(
69
69
  indicator) >= 0,
70
70
  'area-unit-is-valid': indicator.get('term', {}).get("units") == "m2*year",
71
- 'used-country': fallback_country(get_country_id(impact_assessment, blank_node=indicator), [LOOKUP]),
72
71
  'pef-grouping': get_pef_grouping(indicator.get('landCover', {}).get("@id"))
73
72
 
74
73
  } for indicator in land_occupation_indicators]
@@ -79,10 +78,10 @@ def _should_run(impact_assessment: dict) -> Tuple[bool, list]:
79
78
  model=MODEL,
80
79
  term=TERM_ID,
81
80
  lookup_name=LOOKUP,
82
- term_id=indicator['used-country'],
83
- column=indicator['pef-grouping']
84
- ),
85
- "using-fallback-country-region-world-CFs": indicator['used-country'] != indicator['country-id']
81
+ term_id=indicator['country-id'],
82
+ column=indicator['pef-grouping'],
83
+ fallback_world=True
84
+ )
86
85
  } for indicator in found_land_occupation_indicators
87
86
  ]
88
87
 
@@ -8,7 +8,7 @@ from hestia_earth.models.log import logRequirements, logShouldRun, log_as_table
8
8
  from hestia_earth.models.utils.impact_assessment import get_country_id
9
9
  from hestia_earth.models.utils.indicator import _new_indicator
10
10
  from hestia_earth.models.utils.landCover import get_pef_grouping
11
- from hestia_earth.models.utils.lookup import fallback_country, _node_value, get_region_lookup_value
11
+ from hestia_earth.models.utils.lookup import _node_value, get_region_lookup_value
12
12
  from . import MODEL
13
13
 
14
14
  REQUIREMENTS = {
@@ -104,31 +104,28 @@ def _should_run(impact_assessment: dict) -> Tuple[bool, list]:
104
104
  'value-is-valid': (
105
105
  _node_value(indicator) is not None and
106
106
  _node_value(indicator) >= 0
107
- ),
108
- 'lookup-country': fallback_country(
109
- get_country_id(impact_assessment, blank_node=indicator),
110
- [from_lookup_file, to_lookup_file]
111
- ),
107
+ )
112
108
  } for indicator in resource_uses
113
109
  ]
114
110
 
115
111
  found_transformations_with_coefficient = [
116
112
  transformation | {
117
- 'using-fallback-country-region-world-CFs': transformation['lookup-country'] != transformation['country-id'],
118
113
  'factor-from': get_region_lookup_value(
119
114
  model=MODEL,
120
115
  term=TERM_ID,
121
116
  lookup_name=from_lookup_file,
122
- term_id=transformation['lookup-country'],
123
- column=get_pef_grouping(transformation['land-cover-id-from'])) if
124
- transformation['land-cover-id-from'] else None,
117
+ term_id=transformation['country-id'],
118
+ column=get_pef_grouping(transformation['land-cover-id-from']),
119
+ fallback_world=True
120
+ ) if transformation['land-cover-id-from'] else None,
125
121
  'factor-to': get_region_lookup_value(
126
122
  model=MODEL,
127
123
  term=TERM_ID,
128
124
  lookup_name=to_lookup_file,
129
- term_id=transformation['lookup-country'],
130
- column=get_pef_grouping(transformation['land-cover-id-to'])) if
131
- transformation['land-cover-id-to'] else None
125
+ term_id=transformation['country-id'],
126
+ column=get_pef_grouping(transformation['land-cover-id-to']),
127
+ fallback_world=True
128
+ ) if transformation['land-cover-id-to'] else None
132
129
  } for transformation in found_transformations
133
130
  ]
134
131
 
@@ -28,7 +28,7 @@ def _indicator(value: float):
28
28
 
29
29
  def run(impact_assessment: dict):
30
30
  value = impact_emission_lookup_value(
31
- model=MODEL, term_id=TERM_ID, impact=impact_assessment, lookup_col=LOOKUPS['emission'], grouped_key='default'
31
+ model=MODEL, term_id=TERM_ID, impact=impact_assessment, lookup_col=LOOKUPS['emission'], group_key='default'
32
32
  )
33
33
  logRequirements(impact_assessment, model=MODEL, term=TERM_ID,
34
34
  value=value)
@@ -750,6 +750,22 @@ def _should_run_historical_land_use_change_total_cropland(site: dict, nodes: lis
750
750
  return all([should_run_annual, should_run_permanent]), scaled_results
751
751
 
752
752
 
753
+ def _log_all_terms(site: dict, land_use_type: str, country_id: str, changes: dict, missing_changes: list):
754
+ for land_use_term, _ in LAND_USE_TERMS_FOR_TRANSFORMATION.values():
755
+ logRequirements(
756
+ log_node=site,
757
+ model=MODEL,
758
+ term=land_use_term,
759
+ model_key=MODEL_KEY,
760
+ land_use_type=land_use_type,
761
+ country_id=country_id,
762
+ changes=log_as_table(changes),
763
+ missing_changes=log_as_table(missing_changes)
764
+ )
765
+
766
+ logShouldRun(site, MODEL, land_use_term, False, model_key=MODEL_KEY)
767
+
768
+
753
769
  def _should_run_historical_land_use_change_single_crop(
754
770
  site: dict,
755
771
  term: dict,
@@ -909,6 +925,14 @@ def _should_run_historical_land_use_change_single_crop(
909
925
 
910
926
  should_run = all([len(missing_changes) == 0, country_id, site_type_allowed, sum_of_site_areas_is_100])
911
927
  logShouldRun(site, MODEL, term.get("@id"), should_run, model_key=MODEL_KEY)
928
+ if not should_run:
929
+ _log_all_terms(
930
+ site=site,
931
+ land_use_type=land_use_type,
932
+ country_id=country_id,
933
+ changes=changes,
934
+ missing_changes=missing_changes
935
+ )
912
936
 
913
937
  return should_run, capped_site_area
914
938
 
@@ -1,4 +1,5 @@
1
1
  from functools import reduce
2
+ from itertools import zip_longest
2
3
  from typing import NamedTuple
3
4
 
4
5
  from hestia_earth.models.log import logRequirements, logShouldRun, log_as_table
@@ -29,9 +30,9 @@ REQUIREMENTS = {
29
30
  "@type": "Site",
30
31
  "country": {"@type": "Term", "termType": "region"}
31
32
  },
32
- "siteArea": "",
33
- "siteDuration": "",
34
- "siteUnusedDuration": "",
33
+ "siteArea": ">= 0",
34
+ "siteDuration": ">= 0",
35
+ "siteUnusedDuration": ">= 0",
35
36
  "optional": {
36
37
  "@doc": "When `otherSites` are provided, `otherSitesArea`, `otherSitesDuration` and `otherSitesUnusedDuration` are required", # noqa: E501
37
38
  "otherSites": [{
@@ -111,82 +112,102 @@ def _calc_land_occupation_m2_per_kg(
111
112
  return land_occupation_m2_per_ha * economic_value_share * 0.01 / yield_
112
113
 
113
114
 
114
- _CYCLE_KEYS = (
115
- "site", "siteArea", "siteDuration", "siteUnusedDuration"
116
- )
115
+ def _extract_site_data(cycle: dict, land_cover_id: dict):
116
+ site = cycle.get("site", {})
117
+ site_data = SiteData(
118
+ id=site.get("@id"),
119
+ area=cycle.get("siteArea"),
120
+ duration=cycle.get("siteDuration"),
121
+ unused_duration=cycle.get("siteUnusedDuration"),
122
+ country_id=site.get("country", {}).get("@id"),
123
+ land_cover_id=land_cover_id or get_landCover_term_id_from_site_type(site.get("siteType", {}))
124
+ )
117
125
 
118
- _CYCLE_KEY_MAPPING = {
119
- field: field.replace("site", "otherSites", 1) for field in _CYCLE_KEYS
120
- }
126
+ is_valid = _should_run_site_data(site_data)
121
127
 
128
+ logs = {
129
+ "site_data": _format_inventory([site_data])
130
+ }
122
131
 
123
- def _build_inventory(cycle: dict, product: dict):
124
- product_land_cover_id = get_landCover_term_id(product.get("term", {}), skip_debug=True)
132
+ return is_valid, site_data, logs
125
133
 
126
- cycle_data = {
127
- key: [value] + cycle.get(otherSites_key, []) for key, otherSites_key in _CYCLE_KEY_MAPPING.items()
128
- if (value := cycle.get(key))
129
- }
130
134
 
131
- n_sites = len(cycle_data.get("site", []))
132
- should_build_inventory = n_sites > 0 and all([
133
- len(cycle_data.get(key, [])) == n_sites for key in _CYCLE_KEYS[1:]
134
- ])
135
+ def _extract_other_sites_data(cycle: dict, land_cover_id: dict):
136
+ other_sites = cycle.get("otherSites", [])
137
+ other_sites_area = cycle.get("otherSitesArea", [])
138
+ other_sites_duration = cycle.get("otherSitesDuration", [])
139
+ other_sites_unused_duration = cycle.get("otherSitesUnusedDuration", [])
135
140
 
136
- inventory = [
141
+ other_sites_data = [
137
142
  SiteData(
138
143
  id=site.get("@id"),
139
- area=cycle_data["siteArea"][i],
140
- duration=cycle_data["siteDuration"][i],
141
- unused_duration=cycle_data["siteUnusedDuration"][i],
144
+ area=area,
145
+ duration=duration,
146
+ unused_duration=unused_duration,
142
147
  country_id=site.get("country", {}).get("@id"),
143
- land_cover_id=product_land_cover_id or get_landCover_term_id_from_site_type(site.get("siteType", {}))
144
- ) for i, site in enumerate(cycle_data.get("site", []))
145
- ] if should_build_inventory else []
148
+ land_cover_id=land_cover_id or get_landCover_term_id_from_site_type(site.get("siteType", {}))
149
+ ) for (
150
+ site,
151
+ area,
152
+ duration,
153
+ unused_duration
154
+ ) in zip_longest(
155
+ other_sites,
156
+ other_sites_area,
157
+ other_sites_duration,
158
+ other_sites_unused_duration
159
+ )
160
+ ]
161
+
162
+ is_valid = all(_should_run_site_data(other_site) for other_site in other_sites_data)
146
163
 
147
164
  logs = {
148
- "n_sites": n_sites
165
+ "other_sites_count": len(other_sites),
166
+ "other_sites_data": _format_inventory(other_sites_data, "Not relevant")
149
167
  }
150
168
 
151
- return inventory, logs
169
+ return is_valid, other_sites_data, logs
152
170
 
153
171
 
154
172
  def _should_run_site_data(site_data: SiteData) -> bool:
155
173
  return all([
156
- site_data.area >= 0,
157
- site_data.duration >= 0,
158
- site_data.unused_duration >= 0,
174
+ site_data.area or site_data.area == 0,
175
+ site_data.duration or site_data.duration == 0,
176
+ site_data.unused_duration or site_data.unused_duration == 0,
159
177
  site_data.land_cover_id,
160
178
  site_data.country_id
161
179
  ])
162
180
 
163
181
 
164
- def _format_float(value: float, unit: str = "") -> str:
182
+ def _format_float(value: float, unit: str = "", default: str = "None") -> str:
165
183
  return " ".join(
166
- string for string in [f"{value:.3g}", unit] if string
167
- ) if value else "None"
184
+ string for string in [f"{value}", unit] if string
185
+ ) if isinstance(value, (float, int)) else default
168
186
 
169
187
 
170
188
  _INVALID_CHARS = {"_", ":", ",", "="}
171
189
  _REPLACEMENT_CHAR = "-"
172
190
 
173
191
 
174
- def _format_str(value: str) -> str:
192
+ def _format_str(value: str, default: str = "None") -> str:
175
193
  """Format a string for logging in a table. Remove all characters used to render the table on the front end."""
176
- return reduce(lambda x, char: x.replace(char, _REPLACEMENT_CHAR), _INVALID_CHARS, str(value))
194
+ return (
195
+ reduce(lambda x, char: x.replace(char, _REPLACEMENT_CHAR), _INVALID_CHARS, str(value))
196
+ if value else default
197
+ )
177
198
 
178
199
 
179
- def _format_inventory(inventory: list[SiteData]) -> str:
200
+ def _format_inventory(inventory: list[SiteData], default: str = "None") -> str:
180
201
  return log_as_table(
181
202
  {
182
- "@id": _format_str(site_data.id),
203
+ "site-id": _format_str(site_data.id),
183
204
  "site-area": _format_float(site_data.area, "ha"),
184
205
  "site-duration": _format_float(site_data.duration, "days"),
185
206
  "site-unused-duration": _format_float(site_data.unused_duration, "days"),
186
207
  "land-cover-id": _format_str(site_data.land_cover_id),
187
208
  "country-id": _format_str(site_data.country_id)
188
209
  } for site_data in inventory
189
- ) if inventory else "None"
210
+ ) if inventory else default
190
211
 
191
212
 
192
213
  def _should_run(impact_assessment: dict):
@@ -195,13 +216,21 @@ def _should_run(impact_assessment: dict):
195
216
  functional_unit = cycle.get("functionalUnit")
196
217
 
197
218
  product = get_product(impact_assessment)
198
- yield_ = sum(product.get("value", []))
219
+ product_yield = sum(product.get("value", []))
220
+ product_land_cover_id = get_landCover_term_id(product.get("term", {}), skip_debug=True)
199
221
  economic_value_share = (
200
222
  100 if functional_unit == CycleFunctionalUnit.RELATIVE.value
201
223
  else product.get("economicValueShare")
202
224
  )
203
225
 
204
- inventory, logs = _build_inventory(cycle, product)
226
+ site_data_is_valid, site_data, site_logs = _extract_site_data(cycle, product_land_cover_id)
227
+ (
228
+ other_sites_data_is_valid,
229
+ other_sites_data,
230
+ other_sites_logs
231
+ ) = _extract_other_sites_data(cycle, product_land_cover_id)
232
+
233
+ inventory = [site_data] + other_sites_data
205
234
 
206
235
  valid_inventory = inventory and all(_should_run_site_data(site_data) for site_data in inventory)
207
236
 
@@ -210,25 +239,25 @@ def _should_run(impact_assessment: dict):
210
239
  model=MODEL,
211
240
  term=TERM_ID,
212
241
  functional_unit=functional_unit,
213
- yield_=_format_float(yield_, product.get("term", {}).get("units")),
242
+ product_yield=_format_float(product_yield, product.get("term", {}).get("units")),
214
243
  economic_value_share=_format_float(economic_value_share, "pct"),
215
- site_inventory=_format_inventory(inventory),
216
244
  valid_inventory=valid_inventory,
217
- **logs
245
+ site_data_is_valid=site_data_is_valid,
246
+ **site_logs,
247
+ other_sites_data_is_valid=other_sites_data_is_valid,
248
+ **other_sites_logs
218
249
  )
219
250
 
220
251
  should_run = all([
221
- yield_ > 0,
222
- (
223
- economic_value_share is not None
224
- and economic_value_share >= 0
225
- ),
226
- valid_inventory
252
+ product_yield > 0,
253
+ economic_value_share or economic_value_share == 0,
254
+ site_data_is_valid,
255
+ other_sites_data_is_valid
227
256
  ])
228
257
 
229
258
  logShouldRun(impact_assessment, MODEL, TERM_ID, should_run)
230
259
 
231
- return should_run, yield_, economic_value_share, inventory
260
+ return should_run, product_yield, economic_value_share, inventory
232
261
 
233
262
 
234
263
  def _run(
@@ -17,7 +17,13 @@ REQUIREMENTS = {
17
17
  "value": ">=0"
18
18
  }
19
19
  ],
20
- "endDate": ""
20
+ "endDate": "",
21
+ "none": {
22
+ "cycle": {
23
+ "@type": "Cycle",
24
+ "otherSites": []
25
+ }
26
+ }
21
27
  }
22
28
  }
23
29
  RETURNS = {
@@ -17,7 +17,13 @@ REQUIREMENTS = {
17
17
  "value": ">=0"
18
18
  }
19
19
  ],
20
- "endDate": ""
20
+ "endDate": "",
21
+ "none": {
22
+ "cycle": {
23
+ "@type": "Cycle",
24
+ "otherSites": []
25
+ }
26
+ }
21
27
  }
22
28
  }
23
29
  RETURNS = {