hestia-earth-models 0.65.5__py3-none-any.whl → 0.65.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hestia-earth-models might be problematic. Click here for more details.

Files changed (57) hide show
  1. hestia_earth/models/cml2001Baseline/abioticResourceDepletionFossilFuels.py +9 -5
  2. hestia_earth/models/config/Cycle.json +2193 -0
  3. hestia_earth/models/config/ImpactAssessment.json +2041 -0
  4. hestia_earth/models/config/Site.json +471 -0
  5. hestia_earth/models/config/__init__.py +71 -0
  6. hestia_earth/models/config/run-calculations.json +42 -0
  7. hestia_earth/models/config/trigger-calculations.json +43 -0
  8. hestia_earth/models/hestia/landCover.py +70 -22
  9. hestia_earth/models/ipcc2019/animal/hoursWorkedPerDay.py +38 -0
  10. hestia_earth/models/mocking/search-results.json +833 -829
  11. hestia_earth/models/site/management.py +82 -22
  12. hestia_earth/models/utils/crop.py +5 -1
  13. hestia_earth/models/version.py +1 -1
  14. hestia_earth/orchestrator/__init__.py +40 -0
  15. hestia_earth/orchestrator/log.py +62 -0
  16. hestia_earth/orchestrator/models/__init__.py +118 -0
  17. hestia_earth/orchestrator/models/emissions/__init__.py +0 -0
  18. hestia_earth/orchestrator/models/emissions/deleted.py +15 -0
  19. hestia_earth/orchestrator/models/transformations.py +103 -0
  20. hestia_earth/orchestrator/strategies/__init__.py +0 -0
  21. hestia_earth/orchestrator/strategies/merge/__init__.py +42 -0
  22. hestia_earth/orchestrator/strategies/merge/merge_append.py +29 -0
  23. hestia_earth/orchestrator/strategies/merge/merge_default.py +1 -0
  24. hestia_earth/orchestrator/strategies/merge/merge_list.py +103 -0
  25. hestia_earth/orchestrator/strategies/merge/merge_node.py +59 -0
  26. hestia_earth/orchestrator/strategies/run/__init__.py +8 -0
  27. hestia_earth/orchestrator/strategies/run/add_blank_node_if_missing.py +85 -0
  28. hestia_earth/orchestrator/strategies/run/add_key_if_missing.py +9 -0
  29. hestia_earth/orchestrator/strategies/run/always.py +6 -0
  30. hestia_earth/orchestrator/utils.py +116 -0
  31. {hestia_earth_models-0.65.5.dist-info → hestia_earth_models-0.65.7.dist-info}/METADATA +27 -5
  32. {hestia_earth_models-0.65.5.dist-info → hestia_earth_models-0.65.7.dist-info}/RECORD +57 -13
  33. tests/models/cml2001Baseline/test_abioticResourceDepletionFossilFuels.py +1 -1
  34. tests/models/hestia/test_landCover.py +2 -1
  35. tests/models/ipcc2019/animal/test_hoursWorkedPerDay.py +22 -0
  36. tests/models/test_config.py +115 -0
  37. tests/orchestrator/__init__.py +0 -0
  38. tests/orchestrator/models/__init__.py +0 -0
  39. tests/orchestrator/models/emissions/__init__.py +0 -0
  40. tests/orchestrator/models/emissions/test_deleted.py +21 -0
  41. tests/orchestrator/models/test_transformations.py +29 -0
  42. tests/orchestrator/strategies/__init__.py +0 -0
  43. tests/orchestrator/strategies/merge/__init__.py +0 -0
  44. tests/orchestrator/strategies/merge/test_merge_append.py +33 -0
  45. tests/orchestrator/strategies/merge/test_merge_default.py +7 -0
  46. tests/orchestrator/strategies/merge/test_merge_list.py +327 -0
  47. tests/orchestrator/strategies/merge/test_merge_node.py +95 -0
  48. tests/orchestrator/strategies/run/__init__.py +0 -0
  49. tests/orchestrator/strategies/run/test_add_blank_node_if_missing.py +114 -0
  50. tests/orchestrator/strategies/run/test_add_key_if_missing.py +14 -0
  51. tests/orchestrator/strategies/run/test_always.py +5 -0
  52. tests/orchestrator/test_models.py +69 -0
  53. tests/orchestrator/test_orchestrator.py +27 -0
  54. tests/orchestrator/test_utils.py +109 -0
  55. {hestia_earth_models-0.65.5.dist-info → hestia_earth_models-0.65.7.dist-info}/LICENSE +0 -0
  56. {hestia_earth_models-0.65.5.dist-info → hestia_earth_models-0.65.7.dist-info}/WHEEL +0 -0
  57. {hestia_earth_models-0.65.5.dist-info → hestia_earth_models-0.65.7.dist-info}/top_level.txt +0 -0
@@ -10,11 +10,18 @@ tillage, cropResidueManagement and landUseManagement.
10
10
  All values are copied from the source node, except for crop and forage terms in which case the dates are copied from the
11
11
  cycle.
12
12
 
13
+ Where `startDate` is missing from landCover products, gap-filling is attempted using `endDate` - `maximumCycleDuration`.
14
+ This is the `endDate` of the `landCover` product.
15
+ This ensures no overlapping date ranges.
16
+ If both `endDate` and `startDate` are missing from the product, these will be gap-filled from the `Cycle`.
17
+
13
18
  When nodes are chronologically consecutive with "% area" or "boolean" units and the same term and value, they are
14
19
  condensed into a single node to aid readability.
15
20
  """
21
+ from datetime import timedelta, datetime
16
22
  from functools import reduce
17
23
  from hestia_earth.schema import TermTermType, SiteSiteType
24
+ from hestia_earth.utils.lookup import column_name, get_table_value, download_lookup
18
25
  from hestia_earth.utils.model import filter_list_term_type
19
26
  from hestia_earth.utils.tools import safe_parse_float, flatten
20
27
  from hestia_earth.utils.blank_node import get_node_value
@@ -23,12 +30,12 @@ from hestia_earth.models.log import logRequirements, logShouldRun, log_as_table
23
30
  from hestia_earth.models.utils import _include, _omit, group_by
24
31
  from hestia_earth.models.utils.management import _new_management
25
32
  from hestia_earth.models.utils.term import get_lookup_value
26
- from hestia_earth.models.utils.blank_node import condense_nodes
27
- from hestia_earth.models.utils.crop import get_landCover_term_id
33
+ from hestia_earth.models.utils.blank_node import condense_nodes, DatestrFormat, _gapfill_datestr, DatestrGapfillMode
28
34
  from hestia_earth.models.utils.site import (
29
35
  related_cycles, get_land_cover_term_id as get_landCover_term_id_from_site_type
30
36
  )
31
37
  from . import MODEL
38
+ from ..utils.crop import get_landCover_term_id
32
39
 
33
40
  REQUIREMENTS = {
34
41
  "Site": {
@@ -81,13 +88,13 @@ RETURNS = {
81
88
  }]
82
89
  }
83
90
  LOOKUPS = {
84
- "crop": ["landCoverTermId"],
91
+ "crop": ["landCoverTermId", "maximumCycleDuration"],
85
92
  "forage": ["landCoverTermId"],
86
93
  "inorganicFertiliser": "nitrogenContent",
87
94
  "organicFertiliser": "ANIMAL_MANURE",
88
95
  "soilAmendment": "PRACTICE_INCREASING_C_INPUT",
89
96
  "landUseManagement": "GAP_FILL_TO_MANAGEMENT",
90
- "property": "GAP_FILL_TO_MANAGEMENT"
97
+ "property": ["GAP_FILL_TO_MANAGEMENT", "CALCULATE_TOTAL_LAND_COVER_SHARE_SEPARATELY"]
91
98
  }
92
99
  MODEL_KEY = 'management'
93
100
 
@@ -126,19 +133,49 @@ _INPUT_RULES = {
126
133
  _SKIP_LAND_COVER_SITE_TYPES = [
127
134
  SiteSiteType.CROPLAND.value
128
135
  ]
136
+ _CYCLE_DATE_TERM_TYPES = {TermTermType.CROP.value, TermTermType.FORAGE.value}
129
137
 
130
138
 
131
139
  def management(data: dict):
132
140
  node = _new_management(data.get('id'))
133
141
  node['value'] = data['value']
134
- node['endDate'] = data['endDate']
142
+ node['endDate'] = _gap_filled_date_only_str(data['endDate'])
135
143
  if data.get('startDate'):
136
- node['startDate'] = data['startDate']
144
+ node['startDate'] = _gap_filled_date_only_str(date_str=data['startDate'], mode=DatestrGapfillMode.START)
137
145
  if data.get('properties'):
138
146
  node['properties'] = data['properties']
139
147
  return node
140
148
 
141
149
 
150
+ def _get_maximum_cycle_duration(land_cover_id: str):
151
+ lookup = download_lookup("crop.csv")
152
+ return safe_parse_float(
153
+ get_table_value(lookup, column_name('landCoverTermId'), land_cover_id, column_name('maximumCycleDuration'))
154
+ )
155
+
156
+
157
+ def _gap_filled_date_only_str(date_str: str, mode: str = DatestrGapfillMode.END) -> str:
158
+ return _gapfill_datestr(datestr=date_str, mode=mode)[:10]
159
+
160
+
161
+ def _gap_filled_date_obj(date_str: str, mode: str = DatestrGapfillMode.END) -> datetime:
162
+ return datetime.strptime(
163
+ _gap_filled_date_only_str(date_str=date_str, mode=mode),
164
+ DatestrFormat.YEAR_MONTH_DAY.value
165
+ )
166
+
167
+
168
+ def _gap_filled_start_date(land_cover_id: str, end_date: str, cycle: dict) -> str:
169
+ """If possible, gap-fill the startDate based on the endDate - maximumCycleDuration"""
170
+ maximum_cycle_duration = _get_maximum_cycle_duration(land_cover_id)
171
+ return max(
172
+ _gap_filled_date_obj(end_date) - timedelta(days=maximum_cycle_duration)
173
+ if maximum_cycle_duration else datetime.fromtimestamp(0),
174
+ _gap_filled_date_obj(cycle.get("startDate"), mode=DatestrGapfillMode.START)
175
+ if cycle.get("startDate") else datetime.fromtimestamp(0)
176
+ ) if any([maximum_cycle_duration, cycle.get("startDate")]) else None
177
+
178
+
142
179
  def _should_gap_fill(term: dict):
143
180
  value = get_lookup_value(lookup_term=term, column='GAP_FILL_TO_MANAGEMENT')
144
181
  return bool(value)
@@ -167,20 +204,27 @@ def _copy_item_if_exists(source: dict, keys: list[str] = None, dest: dict = None
167
204
  return reduce(lambda p, c: p | ({c: source[c]} if source.get(c) else {}), keys or [], dest or {})
168
205
 
169
206
 
170
- def _get_landCover_term_id(product: dict) -> str:
171
- term = product.get('term', {})
172
- return get_landCover_term_id(term, model=MODEL, term=term.get('@id'), model_key=MODEL_KEY)
173
-
174
-
175
207
  def _get_relevant_items(cycle: dict, item_name: str, relevant_terms: list):
176
208
  """
177
209
  Get items from the list of cycles with any of the relevant terms.
178
210
  Also adds dates from Cycle.
179
211
  """
180
- return [
181
- _include(cycle, ["startDate", "endDate"]) | item
212
+ items = [
213
+ _include(cycle, ["startDate", "endDate"]) |
214
+ _include(
215
+ {
216
+ "startDate": _gap_filled_start_date(
217
+ land_cover_id=get_landCover_term_id(item.get('term', {})),
218
+ end_date=item.get("endDate") if "endDate" in item else cycle.get("endDate", ""),
219
+ cycle=cycle
220
+ )
221
+ } if "startDate" not in item else {},
222
+ "startDate"
223
+ ) |
224
+ item
182
225
  for item in filter_list_term_type(cycle.get(item_name, []), relevant_terms)
183
226
  ]
227
+ return items
184
228
 
185
229
 
186
230
  def _process_rule(node: dict, term: dict) -> list:
@@ -228,11 +272,12 @@ def _run_products(cycle: dict, products: list, total_products: int = None, use_c
228
272
  source=product,
229
273
  keys=['properties', 'startDate', 'endDate'],
230
274
  dest={
231
- "term": {'@id': _get_landCover_term_id(product)},
275
+ "term": {'@id': get_landCover_term_id(product.get('term', {}))},
232
276
  "value": round(100 / (total_products or len(products)), 2)
233
277
  }
234
278
  ) | (
235
- default_dates if use_cycle_dates else {}
279
+ default_dates if use_cycle_dates or product.get("term", {}).get("termType") in _CYCLE_DATE_TERM_TYPES
280
+ else {}
236
281
  ))
237
282
  for product in products
238
283
  ]
@@ -242,7 +287,7 @@ def _run_from_landCover(cycle: dict, crop_forage_products: list):
242
287
  """
243
288
  Copy landCover items, and include crop/forage landCover items with properties to count in ratio.
244
289
  """
245
- products = [
290
+ land_cover_products = [
246
291
  _map_to_value(_extract_node_value(
247
292
  _include(
248
293
  value=product,
@@ -254,14 +299,29 @@ def _run_from_landCover(cycle: dict, crop_forage_products: list):
254
299
  relevant_terms=[TermTermType.LANDCOVER]
255
300
  )
256
301
  ]
257
- return products + _run_products(
302
+ return land_cover_products + _run_products(
258
303
  cycle,
259
304
  crop_forage_products,
260
- total_products=len(crop_forage_products) + len(products),
305
+ total_products=len(crop_forage_products) + len(land_cover_products),
261
306
  use_cycle_dates=True
262
307
  )
263
308
 
264
309
 
310
+ def _should_group_landCover(term: dict):
311
+ value = get_lookup_value(lookup_term=term, column='CALCULATE_TOTAL_LAND_COVER_SHARE_SEPARATELY')
312
+ return bool(value)
313
+
314
+
315
+ def _has_prop_grouped_with_landCover(product: dict):
316
+ return bool(
317
+ next((
318
+ p
319
+ for p in product.get('properties', [])
320
+ if _should_group_landCover(p.get('term', {}))
321
+ ), None)
322
+ )
323
+
324
+
265
325
  def _run_from_crop_forage(cycle: dict):
266
326
  products = _get_relevant_items(
267
327
  cycle=cycle,
@@ -269,13 +329,13 @@ def _run_from_crop_forage(cycle: dict):
269
329
  relevant_terms=[TermTermType.CROP, TermTermType.FORAGE]
270
330
  )
271
331
  # only take products with a matching landCover term
272
- products = list(filter(_get_landCover_term_id, products))
332
+ products = [p for p in products if get_landCover_term_id(p.get('term', {}))]
273
333
  # remove any properties that should not get gap-filled
274
334
  products = list(map(_filter_properties, products))
275
335
 
276
- # split products with properties and those without
277
- products_with_gap_filled_props = [p for p in products if p.get('properties')]
278
- products_without_gap_filled_props = [p for p in products if not p.get('properties')]
336
+ # split products with properties that group with landCover
337
+ products_with_gap_filled_props = [p for p in products if _has_prop_grouped_with_landCover(p)]
338
+ products_without_gap_filled_props = [p for p in products if not _has_prop_grouped_with_landCover(p)]
279
339
 
280
340
  return _run_from_landCover(
281
341
  cycle=cycle,
@@ -65,4 +65,8 @@ def valid_site_type(cycle: dict, include_permanent_pasture=False):
65
65
 
66
66
  def get_landCover_term_id(lookup_term: dict, **log_args) -> str:
67
67
  value = get_lookup_value(lookup_term, 'landCoverTermId', **log_args)
68
- return value.split(';')[0] if value else None
68
+ return (
69
+ lookup_term.get("@id") if lookup_term.get("termType") == TermTermType.LANDCOVER.value else
70
+ value.split(';')[0] if value else
71
+ None
72
+ )
@@ -1 +1 @@
1
- VERSION = '0.65.5'
1
+ VERSION = '0.65.7'
@@ -0,0 +1,40 @@
1
+ from pkgutil import extend_path
2
+ from typing import Union, List
3
+ from hestia_earth.utils.tools import current_time_ms
4
+
5
+ from .log import logger
6
+ from .models import run as run_models
7
+
8
+ __path__ = extend_path(__path__, __name__)
9
+
10
+
11
+ def _required(message): raise Exception(message)
12
+
13
+
14
+ def run(data: dict, configuration: dict, stage: Union[int, List[int]] = None) -> dict:
15
+ """
16
+ Runs a set of models on a Node.
17
+
18
+ Parameters
19
+ ----------
20
+ data : dict
21
+ Either a `Cycle`, a `Site` or an `ImpactAssessment`.
22
+ configuration : dict
23
+ Configuration data which defines the order of the models to run.
24
+ stage : int | list[int]
25
+ For multi-stage calculations, will filter models by "stage". Can pass a single or multiple stage.
26
+
27
+ Returns
28
+ -------
29
+ dict
30
+ The data with updated content
31
+ """
32
+ now = current_time_ms()
33
+ node_type = data.get('@type', data.get('type'))
34
+ node_id = data.get('@id', data.get('id'))
35
+ _required('Please provide an "@type" key in your data.') if node_type is None else None
36
+ _required('Please provide a valid configuration.') if (configuration or {}).get('models') is None else None
37
+ logger.info(f"Running models on {node_type}" + f" with id: {node_id}" if node_id else '')
38
+ data = run_models(data, configuration.get('models', []), stage=stage)
39
+ logger.info('time=%s, unit=ms', current_time_ms() - now)
40
+ return data
@@ -0,0 +1,62 @@
1
+ import os
2
+ import sys
3
+ import logging
4
+
5
+ LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
6
+
7
+ # disable root logger
8
+ root_logger = logging.getLogger()
9
+ root_logger.disabled = True
10
+
11
+ # create custom logger
12
+ logger = logging.getLogger('hestia_earth.orchestrator')
13
+ logger.removeHandler(sys.stdout)
14
+ logger.setLevel(logging.getLevelName(LOG_LEVEL))
15
+
16
+
17
+ def log_to_file(filepath: str):
18
+ """
19
+ By default, all logs are saved into a file with path stored in the env variable `LOG_FILENAME`.
20
+ If you do not set the environment variable `LOG_FILENAME`, you can use this function with the file path.
21
+
22
+ Parameters
23
+ ----------
24
+ filepath : str
25
+ Path of the file.
26
+ """
27
+ formatter = logging.Formatter(
28
+ '{"timestamp": "%(asctime)s", "level": "%(levelname)s", "logger": "%(name)s", '
29
+ '"filename": "%(filename)s", "message": "%(message)s"}',
30
+ '%Y-%m-%dT%H:%M:%S%z')
31
+ handler = logging.FileHandler(filepath, encoding='utf-8')
32
+ handler.setFormatter(formatter)
33
+ handler.setLevel(logging.getLevelName(LOG_LEVEL))
34
+ logger.addHandler(handler)
35
+
36
+
37
+ LOG_FILENAME = os.getenv('LOG_FILENAME')
38
+ if LOG_FILENAME is not None:
39
+ log_to_file(LOG_FILENAME)
40
+
41
+
42
+ def _join_args(**kwargs): return ', '.join([f"{key}={value}" for key, value in kwargs.items()])
43
+
44
+
45
+ def _log_node_suffix(node: dict = {}):
46
+ node_type = node.get('@type', node.get('type')) if node else None
47
+ node_id = node.get('@id', node.get('id', node.get('term', {}).get('@id'))) if node else None
48
+ return f"{node_type.lower()}={node_id}, " if node_type else ''
49
+
50
+
51
+ def debugValues(log_node: dict, **kwargs):
52
+ logger.debug(_log_node_suffix(log_node) + _join_args(**kwargs))
53
+
54
+
55
+ def logShouldRun(log_node: dict, model: str, term: str, should_run: bool, **kwargs):
56
+ extra = (', ' + _join_args(**kwargs)) if len(kwargs.keys()) > 0 else ''
57
+ logger.info(_log_node_suffix(log_node) + 'should_run=%s, model=%s, term=%s' + extra, should_run, model, term)
58
+
59
+
60
+ def logShouldMerge(log_node: dict, model: str, term: str, should_merge: bool, **kwargs):
61
+ extra = (', ' + _join_args(**kwargs)) if len(kwargs.keys()) > 0 else ''
62
+ logger.info(_log_node_suffix(log_node) + 'should_merge=%s, model=%s, term=%s' + extra, should_merge, model, term)
@@ -0,0 +1,118 @@
1
+ import os
2
+ from typing import Union, List
3
+ import importlib
4
+ from functools import reduce
5
+ import concurrent.futures
6
+ from copy import deepcopy
7
+ from hestia_earth.utils.tools import non_empty_list
8
+
9
+ from hestia_earth.models.version import VERSION
10
+ from ..log import logger
11
+ from ..utils import get_required_model_param, _snakecase
12
+ from ..strategies.run import should_run
13
+ from ..strategies.merge import merge
14
+
15
+
16
+ def _max_workers(type: str):
17
+ try:
18
+ return int(os.getenv(f"MAX_WORKERS_{type.upper()}"))
19
+ except Exception:
20
+ return None
21
+
22
+
23
+ def _list_except_item(list, item):
24
+ idx = list.index(item)
25
+ return list[:idx] + list[idx+1:]
26
+
27
+
28
+ def _filter_models_stage(models: list, stage: Union[int, List[int]] = None):
29
+ stages = stage if isinstance(stage, list) else [stage] if stage is not None else None
30
+ return models if stage is None else non_empty_list([
31
+ (_filter_models_stage(m, stage) if isinstance(m, list) else m) for m in models if (
32
+ not isinstance(m, dict) or m.get('stage') in stages
33
+ )
34
+ ])
35
+
36
+
37
+ def _import_model(name: str):
38
+ # try to load the model from the default hestia engine, fallback to orchestrator model
39
+ try:
40
+ return {
41
+ 'run': importlib.import_module(f"hestia_earth.models.{name}").run,
42
+ 'version': importlib.import_module('hestia_earth.models.version').VERSION
43
+ }
44
+ except ModuleNotFoundError:
45
+ # try to load the model from the the models folder, fallback to fully specified name
46
+ try:
47
+ return {
48
+ 'run': importlib.import_module(f"hestia_earth.orchestrator.models.{name}").run,
49
+ 'version': importlib.import_module('hestia_earth.orchestrator.version').VERSION
50
+ }
51
+ except ModuleNotFoundError:
52
+ return {
53
+ 'run': importlib.import_module(f"{name}").run,
54
+ 'version': VERSION
55
+ }
56
+
57
+
58
+ def _run_pre_checks(data: dict):
59
+ node_type = _snakecase(data.get('@type', data.get('type')))
60
+ try:
61
+ pre_checks = _import_model('.'.join([node_type, 'pre_checks'])).get('run')
62
+ logger.info('running pre checks for %s', node_type)
63
+ return pre_checks(data)
64
+ except Exception:
65
+ return data
66
+
67
+
68
+ def _run_post_checks(data: dict):
69
+ node_type = _snakecase(data.get('@type', data.get('type')))
70
+ try:
71
+ post_checks = _import_model('.'.join([node_type, 'post_checks'])).get('run')
72
+ logger.info('running post checks for %s', node_type)
73
+ return post_checks(data)
74
+ except Exception:
75
+ return data
76
+
77
+
78
+ def _run_model(data: dict, model: dict, all_models: list):
79
+ module = _import_model(get_required_model_param(model, 'model'))
80
+ # if no value is provided, use all the models but this one
81
+ model_value = model.get('value') or _list_except_item(all_models, model)
82
+ result = module.get('run')(model_value, data)
83
+ return {'data': data, 'model': model, 'version': module.get('version'), 'result': result}
84
+
85
+
86
+ def _run(data: dict, model: dict, all_models: list):
87
+ return _run_model(data, model, all_models) if should_run(data, model) else None
88
+
89
+
90
+ def _run_serie(data: dict, models: list, stage: Union[int, List[int]] = None):
91
+ return reduce(
92
+ lambda prev, m: merge(
93
+ prev, _run_parallel(prev, m, models) if isinstance(m, list) else [_run(deepcopy(prev), m, models)]
94
+ ),
95
+ _filter_models_stage(models, stage=stage),
96
+ data
97
+ )
98
+
99
+
100
+ def _run_parallel(data: dict, model: list, all_models: list):
101
+ results = []
102
+
103
+ max_workers = _max_workers(data.get('@type', data.get('type')))
104
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
105
+ futures = [executor.submit(_run, deepcopy(data), m, all_models) for m in model]
106
+
107
+ for future in concurrent.futures.as_completed(futures):
108
+ results.append(future.result())
109
+
110
+ return results
111
+
112
+
113
+ def run(data: dict, models: list, stage: Union[int, List[int]] = None):
114
+ # run pre-checks if exist
115
+ data = _run_pre_checks(data)
116
+ data = _run_serie(data, models, stage=stage)
117
+ # run post-checks if exist
118
+ return _run_post_checks(data)
File without changes
@@ -0,0 +1,15 @@
1
+ from hestia_earth.utils.tools import non_empty_list
2
+ from hestia_earth.utils.emission import emissions_in_system_boundary
3
+
4
+
5
+ def _run_emission(term_ids: list):
6
+ def run(emission: dict):
7
+ term_id = emission.get('term', {}).get('@id')
8
+ return (emission | {'deleted': True}) if term_id not in term_ids else None
9
+ return run
10
+
11
+
12
+ def run(_models: list, cycle: dict):
13
+ emission_ids = emissions_in_system_boundary()
14
+ emissions = cycle.get('emissions', [])
15
+ return non_empty_list(map(_run_emission(emission_ids), emissions)) if len(emission_ids) > 0 else []
@@ -0,0 +1,103 @@
1
+ from copy import deepcopy
2
+ from functools import reduce
3
+ from hestia_earth.schema import CompletenessJSONLD
4
+ from hestia_earth.utils.lookup import download_lookup, get_table_value, column_name
5
+ from hestia_earth.models.transformation.input.utils import replace_input_field
6
+ from hestia_earth.models.utils.transformation import previous_transformation
7
+ from hestia_earth.models.utils.product import find_by_product
8
+
9
+ from . import run as run_node, _import_model
10
+ from hestia_earth.orchestrator.utils import new_practice, _filter_by_keys
11
+
12
+
13
+ def _full_completeness():
14
+ completeness = CompletenessJSONLD().to_dict()
15
+ keys = list(completeness.keys())
16
+ keys.remove('@type')
17
+ return {'@type': completeness['@type']} | reduce(lambda prev, curr: prev | {curr: True}, keys, {})
18
+
19
+
20
+ def _include_practice(practice: dict):
21
+ term = practice.get('term', {})
22
+ term_type = term.get('termType')
23
+ term_id = term.get('@id')
24
+ lookup = download_lookup(f"{term_type}.csv")
25
+ value = get_table_value(lookup, 'termid', term_id, column_name('includeForTransformation'))
26
+ return False if value is None or value == '' or not value else True
27
+
28
+
29
+ def _copy_from_cycle(cycle: dict, transformation: dict, keys: list):
30
+ data = deepcopy(transformation)
31
+ for key in keys:
32
+ value = transformation.get(key.replace('cycle', 'transformation')) or \
33
+ transformation.get(key) or \
34
+ cycle.get(key)
35
+ if value is not None:
36
+ data[key] = value
37
+ return data
38
+
39
+
40
+ def _convert_transformation(cycle: dict, transformation: dict):
41
+ data = _copy_from_cycle(cycle, transformation, [
42
+ 'functionalUnit', 'site', 'otherSites', 'cycleDuration', 'startDate', 'endDate'
43
+ ])
44
+ data['completeness'] = cycle.get('completeness', _full_completeness())
45
+ data['practices'] = [
46
+ new_practice(transformation.get('term')) # add `term` as a Practice
47
+ ] + transformation.get('practices', []) + [
48
+ p for p in cycle.get('practices', []) if _include_practice(p) # some practices need to be copied over
49
+ ]
50
+ return data
51
+
52
+
53
+ def _run_models(cycle: dict, transformation: dict, models: list):
54
+ data = _convert_transformation(cycle, transformation)
55
+ result = run_node(data, models)
56
+ return _filter_by_keys(result, [
57
+ 'transformationId', 'term', 'inputs', 'products', 'emissions'
58
+ ])
59
+
60
+
61
+ def _apply_transformation_share(previous: dict, current: dict):
62
+ share = current.get('transformedShare', 100)
63
+
64
+ def replace_value(input: dict):
65
+ product = find_by_product(previous, input)
66
+ return {
67
+ **input,
68
+ **replace_input_field(previous, None, input, product, share, 'value'),
69
+ **replace_input_field(previous, None, input, product, share, 'min'),
70
+ **replace_input_field(previous, None, input, product, share, 'max'),
71
+ **replace_input_field(previous, None, input, product, share, 'sd')
72
+ }
73
+
74
+ return current | {'inputs': list(map(replace_value, current.get('inputs', [])))}
75
+
76
+
77
+ def _add_excreta_inputs(previous: dict, current: dict):
78
+ run = _import_model('transformation.input.excreta').get('run')
79
+ cycle = {
80
+ **previous,
81
+ '@type': 'Cycle',
82
+ 'transformations': [current]
83
+ }
84
+ # model will add the inputs directly in the transformation
85
+ run(cycle)
86
+ return current
87
+
88
+
89
+ def _run_transformation(cycle: dict, models: list):
90
+ def run(transformations: list, transformation: dict):
91
+ previous = previous_transformation(cycle, transformations, transformation)
92
+ transformation = _apply_transformation_share(previous, transformation)
93
+ # add missing excreta Input when relevant and apply the value share as well
94
+ transformation = _add_excreta_inputs(previous, transformation)
95
+ transformation = _apply_transformation_share(previous, transformation)
96
+ transformation = _run_models(cycle, transformation, models)
97
+ return transformations + [transformation]
98
+ return run
99
+
100
+
101
+ def run(models: list, cycle: dict):
102
+ transformations = cycle.get('transformations', [])
103
+ return reduce(_run_transformation(cycle, models), transformations, [])
File without changes
@@ -0,0 +1,42 @@
1
+ from functools import reduce
2
+ import pydash
3
+
4
+ from hestia_earth.orchestrator.utils import _non_empty, _non_empty_list, update_node_version
5
+ from . import merge_append
6
+ from . import merge_default
7
+ from . import merge_list
8
+ from . import merge_node
9
+
10
+
11
+ def _non_empty_results(results: list):
12
+ return list(filter(lambda value: _non_empty(value) and _non_empty_list(value.get('result')), results))
13
+
14
+
15
+ def _merge_version(data: dict): return data.get('version') # set as a function to patch it for testing
16
+
17
+
18
+ _STRATEGIES = {
19
+ 'list': merge_list.merge,
20
+ 'append': merge_append.merge,
21
+ 'node': merge_node.merge,
22
+ 'default': merge_default.merge
23
+ }
24
+
25
+
26
+ def _merge_result(data: dict, result: dict):
27
+ model = result.get('model')
28
+ key = model.get('key')
29
+ values = result.get('result')
30
+ version = _merge_version(result)
31
+ merge_type = model.get('mergeStrategy', 'default')
32
+ merge_args = model.get('mergeArgs', {})
33
+ current = data.get(key)
34
+ node_type = data.get('type', data.get('@type'))
35
+ values = [values] if not isinstance(values, list) and merge_type == 'list' else values
36
+ new_value = _STRATEGIES[merge_type](current, values, version, model, merge_args, node_type)
37
+ new_data = pydash.objects.merge({}, data, {key: new_value})
38
+ return update_node_version(version, new_data, data)
39
+
40
+
41
+ def merge(data: dict, results: list):
42
+ return reduce(_merge_result, _non_empty_results(results), data)
@@ -0,0 +1,29 @@
1
+ from functools import reduce
2
+ from hestia_earth.orchestrator.utils import _non_empty_list, update_node_version
3
+
4
+ from hestia_earth.orchestrator.log import logger
5
+
6
+
7
+ def _merge_node(dest: dict, version: str):
8
+ term_id = dest.get('term', {}).get('@id', dest.get('@id'))
9
+ logger.debug('append %s with value: %s', term_id, dest.get('value'))
10
+ return [update_node_version(version, dest)]
11
+
12
+
13
+ _MERGE_BY_TYPE = {
14
+ 'dict': _merge_node,
15
+ 'list': lambda dest, *args: dest
16
+ }
17
+
18
+
19
+ def _merge_el(version: str):
20
+ def merge(source: list, dest: dict):
21
+ ntype = type(dest).__name__
22
+ return source + _MERGE_BY_TYPE.get(ntype, lambda *args: [dest])(dest, version)
23
+ return merge
24
+
25
+
26
+ def merge(source: list, dest, version: str, *args):
27
+ source = source if source is not None else []
28
+ nodes = _non_empty_list(dest if isinstance(dest, list) else [dest])
29
+ return reduce(_merge_el(version), nodes, source)
@@ -0,0 +1 @@
1
+ def merge(_source, dest, *args): return dest